Răsfoiți Sursa

Merge branch 'master' into t/ckeditor5-typing/101

Piotrek Koszuliński 8 ani în urmă
părinte
comite
4658537a9a

+ 51 - 33
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -55,10 +55,16 @@ addTransformationCase( AttributeDelta, SplitDelta, ( a, b, context ) => {
 		return defaultTransform( a, b, context );
 	}
 
+	const undoMode = context.aWasUndone || context.bWasUndone;
 	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
 
 	const deltas = defaultTransform( a, b, context );
 
+	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
+	if ( undoMode || !( b._cloneOperation instanceof InsertOperation ) ) {
+		return deltas;
+	}
+
 	for ( const operation of a.operations ) {
 		// If a node that has been split has it's attribute updated, we should also update attribute of
 		// the node created during splitting.
@@ -292,21 +298,25 @@ addTransformationCase( SplitDelta, AttributeDelta, ( a, b, context ) => {
 
 	a = a.clone();
 
+	const undoMode = context.aWasUndone || context.bWasUndone;
 	const splitPosition = new Position( a.position.root, a.position.path.slice( 0, -1 ) );
 
-	if ( a._cloneOperation instanceof InsertOperation ) {
-		// If element to split had it's attribute changed, we have to reflect this change in an element
-		// that is in SplitDelta's InsertOperation.
-		for ( const operation of b.operations ) {
-			if ( operation.range.containsPosition( splitPosition ) || operation.range.start.isEqual( splitPosition ) ) {
-				if ( operation.newValue !== null ) {
-					a._cloneOperation.nodes.getNode( 0 ).setAttribute( operation.key, operation.newValue );
-				} else {
-					a._cloneOperation.nodes.getNode( 0 ).removeAttribute( operation.key );
-				}
-
-				break;
+	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
+	if ( undoMode || !( a._cloneOperation instanceof InsertOperation ) ) {
+		return [ a ];
+	}
+
+	// If element to split had it's attribute changed, we have to reflect this change in an element
+	// that is in SplitDelta's InsertOperation.
+	for ( const operation of b.operations ) {
+		if ( operation.range.containsPosition( splitPosition ) || operation.range.start.isEqual( splitPosition ) ) {
+			if ( operation.newValue !== null ) {
+				a._cloneOperation.nodes.getNode( 0 ).setAttribute( operation.key, operation.newValue );
+			} else {
+				a._cloneOperation.nodes.getNode( 0 ).removeAttribute( operation.key );
 			}
+
+			break;
 		}
 	}
 
@@ -383,18 +393,16 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, context ) => {
 // Add special case for RenameDelta x SplitDelta transformation.
 addTransformationCase( RenameDelta, SplitDelta, ( a, b, context ) => {
 	const undoMode = context.aWasUndone || context.bWasUndone;
+	const deltas = defaultTransform( a, b, context );
 
-	// The "clone operation" may be `InsertOperation`, `ReinsertOperation`, `MoveOperation` or `NoOperation`.
-	// `MoveOperation` has `targetPosition` which we want to use. `NoOperation` has no `position` and we don't use special case then.
-	let insertPosition = b._cloneOperation.position || b._cloneOperation.targetPosition;
-
-	if ( insertPosition ) {
-		insertPosition = insertPosition.getShiftedBy( -1 );
+	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
+	if ( undoMode || !( b._cloneOperation instanceof InsertOperation ) ) {
+		return deltas;
 	}
 
-	const deltas = defaultTransform( a, b, context );
+	const insertPosition = b._cloneOperation.position.getShiftedBy( -1 );
 
-	if ( insertPosition && !undoMode && a.operations[ 0 ].position.isEqual( insertPosition ) ) {
+	if ( insertPosition && a.operations[ 0 ].position.isEqual( insertPosition ) ) {
 		// If a node that has been split has it's name changed, we should also change name of
 		// the node created during splitting.
 		const additionalRenameDelta = a.clone();
@@ -408,31 +416,27 @@ addTransformationCase( RenameDelta, SplitDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x RenameDelta transformation.
 addTransformationCase( SplitDelta, RenameDelta, ( a, b, context ) => {
-	const undoMode = context.aWasUndone || context.bWasUndone;
+	a = a.clone();
 
-	// The "clone operation" may be `InsertOperation`, `ReinsertOperation`, `MoveOperation` or `NoOperation`.
-	// `MoveOperation` has `targetPosition` which we want to use. `NoOperation` has no `position` and we don't use special case then.
-	let insertPosition = a._cloneOperation.position || a._cloneOperation.targetPosition;
+	const undoMode = context.aWasUndone || context.bWasUndone;
 
-	if ( insertPosition ) {
-		insertPosition = insertPosition.getShiftedBy( -1 );
+	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
+	if ( undoMode || !( a._cloneOperation instanceof InsertOperation ) ) {
+		return [ a ];
 	}
 
+	const insertPosition = a._cloneOperation.position.getShiftedBy( -1 );
+
 	// If element to split had it's name changed, we have to reflect this by creating additional rename operation.
 	if ( insertPosition && !undoMode && b.operations[ 0 ].position.isEqual( insertPosition ) ) {
 		const additionalRenameDelta = b.clone();
 		additionalRenameDelta.operations[ 0 ].position = insertPosition.getShiftedBy( 1 );
-
-		// `nodes` is a property that is available only if `SplitDelta` `a` has `InsertOperation`.
-		// `SplitDelta` may have `ReinsertOperation` instead of `InsertOperation`.
-		// However, such delta is only created when `MergeDelta` is reversed.
-		// So if this is not undo mode, it means that `SplitDelta` has `InsertOperation`.
 		additionalRenameDelta.operations[ 0 ].oldName = a._cloneOperation.nodes.getNode( 0 ).name;
 
-		return [ a.clone(), additionalRenameDelta ];
+		return [ a, additionalRenameDelta ];
 	}
 
-	return [ a.clone() ];
+	return [ a ];
 } );
 
 // Add special case for RemoveDelta x SplitDelta transformation.
@@ -446,6 +450,13 @@ addTransformationCase( RemoveDelta, SplitDelta, ( a, b, context ) => {
 		return defaultTransform( a, b, context );
 	}
 
+	const undoMode = context.aWasUndone || context.bWasUndone;
+
+	// Special case applies only if undo is not a context.
+	if ( undoMode ) {
+		return deltas;
+	}
+
 	// In case if `defaultTransform` returned more than one delta.
 	for ( const delta of deltas ) {
 		// "No delta" may be returned in some cases.
@@ -464,6 +475,13 @@ addTransformationCase( RemoveDelta, SplitDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x RemoveDelta transformation.
 addTransformationCase( SplitDelta, RemoveDelta, ( a, b, context ) => {
+	const undoMode = context.aWasUndone || context.bWasUndone;
+
+	// Special case applies only if undo is not a context.
+	if ( undoMode ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// This case is very trickily solved.
 	// Instead of fixing `a` delta, we change `b` delta for a while and fire default transformation with fixed `b` delta.
 	// Thanks to that fixing `a` delta will be differently (correctly) transformed.

+ 9 - 7
packages/ckeditor5-engine/src/model/documentselection.js

@@ -85,7 +85,7 @@ export default class DocumentSelection extends Selection {
 
 			// Whenever element which had selection's attributes stored in it stops being empty,
 			// the attributes need to be removed.
-			clearAttributesStoredInElement( changes, batch );
+			clearAttributesStoredInElement( changes, batch, this._document );
 		} );
 	}
 
@@ -312,7 +312,7 @@ export default class DocumentSelection extends Selection {
 
 		const liveRange = LiveRange.createFromRange( range );
 
-		this.listenTo( liveRange, 'change', ( evt, oldRange, data ) => {
+		this.listenTo( liveRange, 'change:range', ( evt, oldRange, data ) => {
 			// If `LiveRange` is in whole moved to the graveyard, fix that range.
 			if ( liveRange.root == this._document.graveyard ) {
 				const sourceStart = data.sourcePosition;
@@ -691,7 +691,7 @@ function getAttrsIfCharacter( node ) {
 }
 
 // Removes selection attributes from element which is not empty anymore.
-function clearAttributesStoredInElement( changes, batch ) {
+function clearAttributesStoredInElement( changes, batch, document ) {
 	// Batch may not be passed to the document#change event in some tests.
 	// See https://github.com/ckeditor/ckeditor5-engine/issues/1001#issuecomment-314202352
 	// Ignore also transparent batches because they are... transparent.
@@ -707,9 +707,11 @@ function clearAttributesStoredInElement( changes, batch ) {
 		return;
 	}
 
-	const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
+	document.enqueueChanges( () => {
+		const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
 
-	for ( const key of storedAttributes ) {
-		batch.removeAttribute( changeParent, key );
-	}
+		for ( const key of storedAttributes ) {
+			batch.removeAttribute( changeParent, key );
+		}
+	} );
 }

+ 35 - 5
packages/ckeditor5-engine/src/model/liverange.js

@@ -77,9 +77,10 @@ export default class LiveRange extends Range {
 	 */
 
 	/**
-	 * Fired when `LiveRange` instance is changed due to changes in the {@link module:engine/model/document~Document document}.
+	 * Fired when `LiveRange` instance boundaries have changed due to changes in the
+	 * {@link module:engine/model/document~Document document}.
 	 *
-	 * @event change
+	 * @event change:range
 	 * @param {module:engine/model/range~Range} oldRange Range with start and end position equal to start and end position of this live
 	 * range before it got changed.
 	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
@@ -89,6 +90,21 @@ export default class LiveRange extends Range {
 	 * @param {module:engine/model/range~Range} data.range Range containing the result of applied change.
 	 * @param {module:engine/model/position~Position} data.sourcePosition Source position for move, remove and reinsert change types.
 	 */
+
+	/**
+	 * Fired when `LiveRange` instance boundaries have not changed after a change in {@link module:engine/model/document~Document document}
+	 * but the change took place inside the range, effectively changing its content.
+	 *
+	 * @event change:content
+	 * @param {module:engine/model/range~Range} range Range with start and end position equal to start and end position of
+	 * change range.
+	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
+	 * {@link module:engine/model/document~Document#event:change document change event}.
+	 * @param {String} data.type Change type.
+	 * @param {module:engine/model/batch~Batch} data.batch Batch which changed the live range.
+	 * @param {module:engine/model/range~Range} data.range Range containing the result of applied change.
+	 * @param {module:engine/model/position~Position} data.sourcePosition Source position for move, remove and reinsert change types.
+	 */
 }
 
 /**
@@ -152,14 +168,28 @@ function transform( changeType, deltaType, batch, targetRange, sourcePosition )
 
 	const updated = Range.createFromRanges( result );
 
-	// If anything changed, update the range and fire an event.
-	if ( !updated.isEqual( this ) ) {
+	const boundariesChanged = !updated.isEqual( this );
+
+	const rangeExpanded = this.containsPosition( targetPosition );
+	const rangeShrunk = sourcePosition && ( this.containsPosition( sourcePosition ) || this.start.isEqual( sourcePosition ) );
+	const contentChanged = rangeExpanded || rangeShrunk;
+
+	if ( boundariesChanged ) {
+		// If range boundaries have changed, fire `change:range` event.
 		const oldRange = Range.createFromRange( this );
 
 		this.start = updated.start;
 		this.end = updated.end;
 
-		this.fire( 'change', oldRange, {
+		this.fire( 'change:range', oldRange, {
+			type: changeType,
+			batch,
+			range: targetRange,
+			sourcePosition
+		} );
+	} else if ( contentChanged ) {
+		// If range boundaries have not changed, but there was change inside the range, fire `change:content` event.
+		this.fire( 'change:content', Range.createFromRange( this ), {
 			type: changeType,
 			batch,
 			range: targetRange,

+ 3 - 1
packages/ckeditor5-engine/src/model/markercollection.js

@@ -256,7 +256,9 @@ class Marker {
 		 */
 		this._liveRange = liveRange;
 
-		this._liveRange.delegate( 'change' ).to( this );
+		// Delegating does not work with namespaces. Alternatively, we could delegate all events (using `*`).
+		this._liveRange.delegate( 'change:range' ).to( this );
+		this._liveRange.delegate( 'change:content' ).to( this );
 	}
 
 	/**

+ 4 - 4
packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js

@@ -202,14 +202,14 @@ export function expectDelta( delta, expected ) {
 export function expectOperation( op, params ) {
 	for ( const i in params ) {
 		if ( i == 'type' ) {
-			expect( op ).to.be.instanceof( params[ i ] );
+			expect( op, 'operation type' ).to.be.instanceof( params[ i ] );
 		}
 		else if ( i == 'nodes' ) {
-			expect( Array.from( op.nodes ) ).to.deep.equal( params[ i ] );
+			expect( Array.from( op.nodes ), 'nodes' ).to.deep.equal( params[ i ] );
 		} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
-			expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
+			expect( op[ i ].isEqual( params[ i ] ), 'property ' + i ).to.be.true;
 		} else {
-			expect( op[ i ] ).to.equal( params[ i ] );
+			expect( op[ i ], 'property ' + 1 ).to.equal( params[ i ] );
 		}
 	}
 }

+ 51 - 0
packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js

@@ -15,7 +15,10 @@ import Range from '../../../../src/model/range';
 
 import AttributeDelta from '../../../../src/model/delta/attributedelta';
 import Delta from '../../../../src/model/delta/delta';
+import SplitDelta from '../../../../src/model/delta/splitdelta';
 import AttributeOperation from '../../../../src/model/operation/attributeoperation';
+import MoveOperation from '../../../../src/model/operation/moveoperation';
+import ReinsertOperation from '../../../../src/model/operation/reinsertoperation';
 import NoOperation from '../../../../src/model/operation/nooperation';
 
 import {
@@ -257,6 +260,54 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'change attribute of split element that reinserts from graveyard', () => {
+				const gy = doc.graveyard;
+				const splitDelta = new SplitDelta();
+
+				splitDelta.operations[ 0 ] = new ReinsertOperation(
+					new Position( gy, [ 1 ] ),
+					1,
+					new Position( root, [ 2 ] ),
+					baseVersion
+				);
+
+				splitDelta.operations[ 1 ] = new MoveOperation(
+					new Position( root, [ 1, 4 ] ),
+					4,
+					new Position( root, [ 2, 0 ] ),
+					baseVersion
+				);
+
+				const attributeDelta = new AttributeDelta();
+
+				const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 1, 0 ] ) );
+
+				attributeDelta.addOperation( new AttributeOperation( range, 'key', 'oldValue', 'newValue', baseVersion ) );
+
+				// The split delta was undone.
+				context.bWasUndone = true;
+
+				const transformed = transform( attributeDelta, splitDelta, context );
+
+				baseVersion = attributeDelta.operations.length;
+				// We expect only one delta. Split delta should not affect attribute delta transformation. It was undone.
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range,
+							key: 'key',
+							oldValue: 'oldValue',
+							newValue: 'newValue',
+							baseVersion
+						}
+					]
+				} );
+			} );
+
 			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
 				const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 3 ] ) );
 				const attrDelta = getAttributeDelta( range, 'foo', null, 'bar', 0 );

+ 29 - 0
packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js

@@ -185,6 +185,35 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'removed node has been split - undo context', () => {
+				const sourcePosition = new Position( root, [ 3, 3, 1 ] );
+				const removeDelta = getRemoveDelta( sourcePosition, 1, baseVersion );
+
+				const splitPosition = new Position( root, [ 3, 3, 1, 2 ] );
+				const nodeCopy = new Element( 'x' );
+				const splitDelta = getSplitDelta( splitPosition, nodeCopy, 2, baseVersion );
+
+				context.bWasUndone = true;
+
+				const transformed = transform( removeDelta, splitDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: RemoveDelta,
+					operations: [
+						{
+							type: RemoveOperation,
+							sourcePosition,
+							howMany: 1,
+							baseVersion
+						}
+					]
+				} );
+			} );
+
 			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
 				const noOpSplitDelta = new SplitDelta();
 				noOpSplitDelta.addOperation( new NoOperation( 0 ) );

+ 33 - 0
packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js

@@ -78,6 +78,39 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'split element is renamed but split delta was undone', () => {
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 3, 3 ] ),
+					'p',
+					'li',
+					baseVersion
+				) );
+
+				const splitPosition = new Position( root, [ 3, 3, 3 ] );
+				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				context.bWasUndone = true;
+
+				const transformed = transform( renameDelta, splitDelta, context );
+
+				baseVersion = splitDelta.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: RenameDelta,
+					operations: [
+						{
+							type: RenameOperation,
+							oldName: 'p',
+							newName: 'li',
+							position: new Position( root, [ 3, 3 ] )
+						}
+					]
+				} );
+			} );
+
 			it( 'split element is different than renamed element', () => {
 				const renameDelta = new RenameDelta();
 				renameDelta.addOperation( new RenameOperation(

+ 31 - 0
packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js

@@ -990,6 +990,37 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'split node has been removed - undo context', () => {
+				const removePosition = new Position( root, [ 3, 3, 3 ] );
+				const removeDelta = getRemoveDelta( removePosition, 1, baseVersion );
+
+				context.bWasUndone = true;
+
+				const transformed = transform( splitDelta, removeDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = removeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: splitDelta.operations[ 0 ].position.getShiftedBy( -1 ),
+							baseVersion
+						},
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 0, 3 ] ),
+							howMany: splitDelta.operations[ 1 ].howMany,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
 			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
 				const noOpSplitDelta = new SplitDelta();
 				noOpSplitDelta.addOperation( new NoOperation( 0 ) );

+ 39 - 17
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -1021,23 +1021,6 @@ describe( 'DocumentSelection', () => {
 				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
 			} );
 
-			it( 'are not removed or merged when containing element is merged with another empty element', () => {
-				const emptyP2 = new Element( 'p', null );
-				root.appendChildren( emptyP2 );
-
-				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
-				emptyP2.setAttribute( abcStoreAttrKey, 'bar' );
-
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
-				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
-
-				// <emptyP>{}<emptyP2>
-				doc.batch().merge( Position.createAfter( emptyP ) );
-
-				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
-				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
-			} );
-
 			it( 'are removed even when there is no selection in it', () => {
 				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
 
@@ -1062,15 +1045,51 @@ describe( 'DocumentSelection', () => {
 				batch.merge( Position.createAfter( emptyP ) );
 
 				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+
 				expect( spy.calledOnce ).to.be.true;
 			} );
 
+			it( 'uses document enqueue changes to clear attributes', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
+				selection.setAttribute( 'foo', 'bar' );
+
+				doc.enqueueChanges( () => {
+					doc.batch().insert( rangeInEmptyP.start, 'x' );
+
+					// `emptyP` still has the attribute, because attribute clearing is in enqueued block.
+					expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
+				} );
+
+				// When the dust settles, `emptyP` should not have the attribute.
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+			} );
+
+			it( 'are not removed or merged when containing element is merged with another empty element', () => {
+				const emptyP2 = new Element( 'p', null );
+				root.appendChildren( emptyP2 );
+
+				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
+				emptyP2.setAttribute( abcStoreAttrKey, 'bar' );
+
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
+				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
+
+				// <emptyP>{}<emptyP2>
+				doc.batch().merge( Position.createAfter( emptyP ) );
+
+				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
+				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
+			} );
+
 			it( 'are not removed on transparent batches', () => {
 				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
+				sinon.spy( doc, 'enqueueChanges' );
+
 				doc.batch( 'transparent' ).insert( rangeInEmptyP.start, 'x' );
 
+				expect( doc.enqueueChanges.called ).to.be.false;
 				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
 			} );
 
@@ -1080,8 +1099,11 @@ describe( 'DocumentSelection', () => {
 				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
+				sinon.spy( doc, 'enqueueChanges' );
+
 				doc.batch().rename( emptyP, 'pnew' );
 
+				expect( doc.enqueueChanges.called ).to.be.false;
 				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
 			} );
 		} );

+ 122 - 58
packages/ckeditor5-engine/tests/model/liverange.js

@@ -88,12 +88,12 @@ describe( 'LiveRange', () => {
 		range.detach();
 	} );
 
-	it( 'should fire change event with proper data when it is changed', () => {
+	it( 'should fire change:range event with proper data when its boundaries are changed', () => {
 		const live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 		const copy = Range.createFromRange( live );
 
 		const spy = sinon.spy();
-		live.on( 'change', spy );
+		live.on( 'change:range', spy );
 
 		const moveSource = new Position( root, [ 2 ] );
 		const moveRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 3 ] ) );
@@ -118,18 +118,48 @@ describe( 'LiveRange', () => {
 		expect( spy.args[ 0 ][ 2 ].sourcePosition.isEqual( moveSource ) ).to.be.true;
 	} );
 
+	it( 'should fire change:content event with proper data when content inside the range has changed', () => {
+		const live = new LiveRange( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) );
+
+		const spy = sinon.spy();
+		live.on( 'change:content', spy );
+
+		const moveSource = new Position( root, [ 2, 0 ] );
+		const moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 2 ] ) );
+
+		const changes = {
+			range: moveRange,
+			sourcePosition: moveSource
+		};
+		const batch = {};
+
+		doc.fire( 'change', 'move', changes, batch );
+
+		expect( spy.calledOnce ).to.be.true;
+
+		// First parameter available in event should be a range that is equal to the live range before the live range changed.
+		// We compare to the `live` range, because boundaries should not have changed.
+		expect( spy.args[ 0 ][ 1 ].isEqual( live ) ).to.be.true;
+
+		// Second parameter is an object with data about model changes that caused the live range to change.
+		expect( spy.args[ 0 ][ 2 ].type ).to.equal( 'move' );
+		expect( spy.args[ 0 ][ 2 ].batch ).to.equal( batch );
+		expect( spy.args[ 0 ][ 2 ].range.isEqual( moveRange ) ).to.be.true;
+		expect( spy.args[ 0 ][ 2 ].sourcePosition.isEqual( moveSource ) ).to.be.true;
+	} );
+
 	// Examples may seem weird when you compare them with the tree structure generated at the beginning of tests.
 	// Since change event is fired _after_ operation is executed on tree model, you have to imagine that generated
 	// structure is representing what is _after_ operation is executed. So live LiveRange properties are describing
 	// virtual tree that is not existing anymore and event ranges are operating on the tree generated above.
-	describe( 'should get transformed if', () => {
+	describe( 'should get transformed and fire change:range if', () => {
 		let live, spy;
 
 		beforeEach( () => {
 			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 
 			spy = sinon.spy();
-			live.on( 'change', spy );
+			live.on( 'change:range', spy );
 		} );
 
 		afterEach( () => {
@@ -556,19 +586,15 @@ describe( 'LiveRange', () => {
 		} );
 	} );
 
-	describe( 'should not get transformed if', () => {
-		let otherRoot, spy, live, clone;
-
-		before( () => {
-			otherRoot = doc.createRoot( '$root', 'otherRoot' );
-		} );
+	describe( 'should not get transformed but fire change:content', () => {
+		let spy, live, clone;
 
 		beforeEach( () => {
 			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 			clone = Range.createFromRange( live );
 
 			spy = sinon.spy();
-			live.on( 'change', spy );
+			live.on( 'change:content', spy );
 		} );
 
 		afterEach( () => {
@@ -576,15 +602,98 @@ describe( 'LiveRange', () => {
 		} );
 
 		describe( 'insertion', () => {
-			it( 'is in the same parent as range start and after it', () => {
+			it( 'inside the range', () => {
 				const insertRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 9 ] ) );
 
 				doc.fire( 'change', 'insert', { range: insertRange }, null );
 
 				expect( live.isEqual( clone ) ).to.be.true;
-				expect( spy.called ).to.be.false;
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+
+		describe( 'range move', () => {
+			it( 'inside the range', () => {
+				const moveSource = new Position( root, [ 4 ] );
+				const moveRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 9 ] ) );
+
+				const changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+				expect( spy.calledOnce ).to.be.true;
+			} );
+
+			it( 'from the range', () => {
+				const moveSource = new Position( root, [ 0, 1, 6 ] );
+				const moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 3 ] ) );
+
+				const changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+				expect( spy.calledOnce ).to.be.true;
 			} );
 
+			it( 'from the beginning of range', () => {
+				const moveSource = new Position( root, [ 0, 1, 4 ] );
+				const moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 3 ] ) );
+
+				const changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+				expect( spy.calledOnce ).to.be.true;
+			} );
+
+			it( 'from the range to the range', () => {
+				live.end.path = [ 0, 1, 12 ];
+
+				const moveSource = new Position( root, [ 0, 1, 6 ] );
+				const moveRange = new Range( new Position( root, [ 0, 1, 8 ] ), new Position( root, [ 0, 1, 10 ] ) );
+
+				const changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 1, 12 ] );
+				expect( spy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'should not get transformed and not fire change event if', () => {
+		let otherRoot, spy, live, clone;
+
+		before( () => {
+			otherRoot = doc.createRoot( '$root', 'otherRoot' );
+		} );
+
+		beforeEach( () => {
+			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
+			clone = Range.createFromRange( live );
+
+			spy = sinon.spy();
+			live.on( 'change', spy );
+		} );
+
+		afterEach( () => {
+			live.detach();
+		} );
+
+		describe( 'insertion', () => {
 			it( 'is in the same parent as range end and after it', () => {
 				const insertRange = new Range( new Position( root, [ 0, 2, 7 ] ), new Position( root, [ 0, 2, 9 ] ) );
 
@@ -614,20 +723,6 @@ describe( 'LiveRange', () => {
 		} );
 
 		describe( 'range move', () => {
-			it( 'is to the same parent as range start and after it', () => {
-				const moveSource = new Position( root, [ 4 ] );
-				const moveRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 9 ] ) );
-
-				const changes = {
-					range: moveRange,
-					sourcePosition: moveSource
-				};
-				doc.fire( 'change', 'move', changes, null );
-
-				expect( live.isEqual( clone ) ).to.be.true;
-				expect( spy.called ).to.be.false;
-			} );
-
 			it( 'is to the same parent as range end and after it', () => {
 				const moveSource = new Position( root, [ 4 ] );
 				const moveRange = new Range( new Position( root, [ 0, 2, 3 ] ), new Position( root, [ 0, 2, 5 ] ) );
@@ -656,20 +751,6 @@ describe( 'LiveRange', () => {
 				expect( spy.called ).to.be.false;
 			} );
 
-			it( 'is from the same parent as range start and after it', () => {
-				const moveSource = new Position( root, [ 0, 1, 6 ] );
-				const moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 3 ] ) );
-
-				const changes = {
-					range: moveRange,
-					sourcePosition: moveSource
-				};
-				doc.fire( 'change', 'move', changes, null );
-
-				expect( live.isEqual( clone ) ).to.be.true;
-				expect( spy.called ).to.be.false;
-			} );
-
 			it( 'is from the same parent as range end and after it', () => {
 				const moveSource = new Position( root, [ 0, 2, 4 ] );
 				const moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 2 ] ) );
@@ -725,23 +806,6 @@ describe( 'LiveRange', () => {
 				expect( live.isEqual( clone ) ).to.be.true;
 				expect( spy.called ).to.be.false;
 			} );
-
-			it( 'is inside live range and points to live range', () => {
-				live.end.path = [ 0, 1, 12 ];
-
-				const moveSource = new Position( root, [ 0, 1, 6 ] );
-				const moveRange = new Range( new Position( root, [ 0, 1, 8 ] ), new Position( root, [ 0, 1, 10 ] ) );
-
-				const changes = {
-					range: moveRange,
-					sourcePosition: moveSource
-				};
-				doc.fire( 'change', 'move', changes, null );
-
-				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
-				expect( live.end.path ).to.deep.equal( [ 0, 1, 12 ] );
-				expect( spy.calledOnce ).to.be.false;
-			} );
 		} );
 	} );
 } );

+ 17 - 0
packages/ckeditor5-engine/tests/model/markercollection.js

@@ -253,4 +253,21 @@ describe( 'Marker', () => {
 			marker.getEnd();
 		} ).to.throw( CKEditorError, /^marker-destroyed/ );
 	} );
+
+	it( 'should delegate events from live range', () => {
+		const range = Range.createFromParentsAndOffsets( root, 1, root, 2 );
+		const marker = doc.markers.set( 'name', range );
+
+		const eventRange = sinon.spy();
+		const eventContent = sinon.spy();
+
+		marker.on( 'change:range', eventRange );
+		marker.on( 'change:content', eventContent );
+
+		marker._liveRange.fire( 'change:range', null, {} );
+		marker._liveRange.fire( 'change:content', null, {} );
+
+		expect( eventRange.calledOnce ).to.be.true;
+		expect( eventContent.calledOnce ).to.be.true;
+	} );
 } );