8
0
Prechádzať zdrojové kódy

Introduced core.treeModel.operation.RootAttributeOperation.

Szymon Cofalik 9 rokov pred
rodič
commit
80bcf61a83

+ 41 - 15
packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js

@@ -8,8 +8,10 @@
 import Delta from './delta.js';
 import { register } from '../batch.js';
 import AttributeOperation from '../operation/attributeoperation.js';
+import RootAttributeOperation from '../operation/rootattributeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
+import RootElement from '../rootelement.js';
 import Element from '../element.js';
 
 /**
@@ -77,6 +79,16 @@ export default class AttributeDelta extends Delta {
 	}
 }
 
+/**
+ * To provide specific OT behavior and better collisions solving, change methods ({@link core.treeModel.Batch#setAttr}
+ * and {@link core.treeModel.Batch#removeAttr}) use `RootAttributeDelta` class which inherits from the `Delta` class and may
+ * overwrite some methods.
+ *
+ * @memberOf core.treeModel.delta
+ * @extends core.treeModel.delta.Delta
+ */
+export class RootAttributeDelta extends Delta {}
+
 /**
  * Sets the value of the attribute of the node or on the range.
  *
@@ -107,42 +119,54 @@ register( 'removeAttr', function( key, nodeOrRange ) {
 } );
 
 function attribute( batch, key, value, nodeOrRange ) {
-	const delta = new AttributeDelta();
+	let delta;
 
 	if ( nodeOrRange instanceof Range ) {
-		changeRange( batch.doc, delta, key, value, nodeOrRange );
+		delta = changeRange( batch.doc, key, value, nodeOrRange );
 	} else {
-		changeNode( batch.doc, delta, key, value, nodeOrRange );
+		delta = changeNode( batch.doc, key, value, nodeOrRange );
 	}
 
 	batch.addDelta( delta );
 }
 
-function changeNode( doc, delta, key, value, node ) {
+function changeNode( doc, key, value, node ) {
 	const previousValue = node.getAttribute( key );
-	let range;
+	let range, operation;
+
+	const delta = node instanceof RootElement ? new RootAttributeDelta() : new AttributeDelta();
 
 	if ( previousValue != value ) {
-		if ( node instanceof Element ) {
-			// If we change the attribute of the element, we do not want to change attributes of its children, so
-			// the end on the range can not be put after the closing tag, it should be inside that element with the
-			// offset 0, so the range will contains only the opening tag...
-			range = new Range( Position.createBefore( node ), Position.createFromParentAndOffset( node, 0 ) );
+		if ( node instanceof RootElement ) {
+			// If we change attributes of root element, we have to use `RootAttributeOperation`.
+			operation = new RootAttributeOperation( node, key, previousValue, value, doc.version );
 		} else {
-			// ...but for characters we can not put the range inside it, so we end the range after that character.
-			range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
-		}
+			if ( node instanceof Element ) {
+				// If we change the attribute of the element, we do not want to change attributes of its children, so
+				// the end on the range can not be put after the closing tag, it should be inside that element with the
+				// offset 0, so the range will contains only the opening tag...
+				range = new Range( Position.createBefore( node ), Position.createFromParentAndOffset( node, 0 ) );
+			} else {
+				// ...but for characters we can not put the range inside it, so we end the range after that character.
+				range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
+			}
 
-		const operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+		}
 
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
 	}
+
+	// It is expected that this method returns a delta.
+	return delta;
 }
 
 // Because attribute operation needs to have the same attribute value on the whole range, this function split the range
 // into smaller parts.
-function changeRange( doc, delta, attributeKey, attributeValue, range ) {
+function changeRange( doc, attributeKey, attributeValue, range ) {
+	const delta = new AttributeDelta();
+
 	// Position of the last split, the beginning of the new range.
 	let lastSplitPosition = range.start;
 
@@ -185,4 +209,6 @@ function changeRange( doc, delta, attributeKey, attributeValue, range ) {
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
 	}
+
+	return delta;
 }

+ 6 - 3
packages/ckeditor5-engine/src/treemodel/document.js

@@ -269,7 +269,8 @@ export default class Document {
 	 * * 'remove' when nodes are removed,
 	 * * 'reinsert' when remove is undone,
 	 * * 'move' when nodes are moved,
-	 * * 'attribute' when attributes change.
+	 * * 'attribute' when attributes change,
+	 * * 'rootattribute' when attributes for root element change.
 	 *
 	 * Change event is fired after the change is done. This means that any ranges or positions passed in
 	 * `changeInfo` are referencing nodes and paths in updated tree model.
@@ -277,8 +278,10 @@ export default class Document {
 	 * @event core.treeModel.Document#change
 	 * @param {String} type Change type, possible option: `'insert'`, `'remove'`, `'reinsert'`, `'move'`, `'attribute'`.
 	 * @param {Object} changeInfo Additional information about the change.
-	 * @param {core.treeModel.Range} changeInfo.range Range containing changed nodes. Note that for `'remove'` the range will be in the
-	 * {@link core.treeModel.Document#graveyard graveyard root}.
+	 * @param {core.treeModel.Range} [changeInfo.range] Range containing changed nodes. Note that for `'remove'` the range will be in the
+	 * {@link core.treeModel.Document#graveyard graveyard root}. This is undefined for `'rootattribute'` type.
+	 * @param {core.treeModel.RootElement} [changeInfo.root] Root element which attributes got changed. This is defined
+	 * only for `'rootattribute'` type.
 	 * @param {core.treeModel.Position} [changeInfo.sourcePosition] Change source position. Exists for `'remove'`, `'reinsert'` and `'move'`.
 	 * Note that for 'reinsert' the source position will be in the {@link core.treeModel.Document#graveyard graveyard root}.
 	 * @param {String} [changeInfo.key] Only for `'attribute'` type. Key of changed / inserted / removed attribute.

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -102,7 +102,7 @@ export default class AttributeOperation extends Operation {
 				 */
 				throw new CKEditorError(
 					'operation-attribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
-					{ item: item, key: this.key, value: this.oldValue }
+					{ item: item, key: this.key }
 				);
 			}
 

+ 125 - 0
packages/ckeditor5-engine/src/treemodel/operation/rootattributeoperation.js

@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Operation from './operation.js';
+import CKEditorError from '../../ckeditorerror.js';
+
+/**
+ * Operation to change root element's attribute. Using this class you can add, remove or change value of the attribute.
+ *
+ * This operation is needed, because root elements can't be changed through {@link core.treeModel.operation.AttributeOperation}.
+ * It is because {@link core.treeModel.operation.AttributeOperation} requires a range to change and root element can't
+ * be a part of range because every {@link core.treeModel.Position} has to be inside a root. {@link core.treeModel.Position}
+ * can't be created before a root element.
+ *
+ * @memberOf core.treeModel.operation
+ * @extends core.treeModel.operation.Operation
+ */
+export default class RootAttributeOperation extends Operation {
+	/**
+	 * Creates an operation that changes, removes or adds attributes on root element.
+	 *
+	 * @see core.treeModel.operation.AttributeOperation
+	 * @param {core.treeModel.RootElement} root Root element to change.
+	 * @param {String} key Key of an attribute to change or remove.
+	 * @param {*} oldValue Old value of the attribute with given key or `null` if adding a new attribute.
+	 * @param {*} newValue New value to set for the attribute. If `null`, then the operation just removes the attribute.
+	 * @param {Number} baseVersion {@link core.treeModel.Document#version} on which the operation can be applied.
+	 */
+	constructor( root, key, oldValue, newValue, baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * Root element to change.
+		 *
+		 * @readonly
+		 * @member {core.treeModel.RootElement} core.treeModel.operation.RootAttributeOperation#root
+		 */
+		this.root = root;
+
+		/**
+		 * Key of an attribute to change or remove.
+		 *
+		 * @readonly
+		 * @member {String} core.treeModel.operation.RootAttributeOperation#key
+		 */
+		this.key = key;
+
+		/**
+		 * Old value of the attribute with given key or `null` if adding a new attribute.
+		 *
+		 * @readonly
+		 * @member {*} core.treeModel.operation.RootAttributeOperation#oldValue
+		 */
+		this.oldValue = oldValue;
+
+		/**
+		 * New value to set for the attribute. If `null`, then the operation just removes the attribute.
+		 *
+		 * @readonly
+		 * @member {*} core.treeModel.operation.RootAttributeOperation#newValue
+		 */
+		this.newValue = newValue;
+	}
+
+	get type() {
+		return 'rootattribute';
+	}
+
+	/**
+	 * @returns {core.treeModel.operation.RootAttributeOperation}
+	 */
+	clone() {
+		return new RootAttributeOperation( this.root, this.key, this.oldValue, this.newValue, this.baseVersion );
+	}
+
+	/**
+	 * @returns {core.treeModel.operation.RootAttributeOperation}
+	 */
+	getReversed() {
+		return new RootAttributeOperation( this.root, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
+	}
+
+	_execute() {
+		if ( this.oldValue !== null && this.root.getAttribute( this.key ) !== this.oldValue ) {
+			/**
+			 * The attribute which should be removed does not exists for the given node.
+			 *
+			 * @error operation-rootattribute-no-attr-to-remove
+			 * @param {core.treeModel.RootElement} root
+			 * @param {String} key
+			 * @param {*} value
+			 */
+			throw new CKEditorError(
+				'operation-rootattribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
+				{ root: this.root, key: this.key }
+			);
+		}
+
+		if ( this.oldValue === null && this.newValue !== null && this.root.hasAttribute( this.key ) ) {
+			/**
+			 * The attribute with given key already exists for the given node.
+			 *
+			 * @error operation-rootattribute-attr-exists
+			 * @param {core.treeModel.RootElement} root
+			 * @param {String} key
+			 */
+			throw new CKEditorError(
+				'operation-rootattribute-attr-exists: The attribute with given key already exists.',
+				{ root: this.root, key: this.key }
+			);
+		}
+
+		if ( this.newValue !== null ) {
+			this.root.setAttribute( this.key, this.newValue );
+		} else {
+			this.root.removeAttribute( this.key );
+		}
+
+		return { root: this.root, key: this.key, oldValue: this.oldValue, newValue: this.newValue };
+	}
+}

+ 31 - 0
packages/ckeditor5-engine/src/treemodel/operation/transform.js

@@ -7,6 +7,7 @@
 
 import InsertOperation from './insertoperation.js';
 import AttributeOperation from './attributeoperation.js';
+import RootAttributeOperation from './rootattributeoperation.js';
 import MoveOperation from './moveoperation.js';
 import RemoveOperation from './removeoperation.js';
 import NoOperation from './nooperation.js';
@@ -72,6 +73,8 @@ const ot = {
 
 		AttributeOperation: doNotUpdate,
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -133,6 +136,8 @@ const ot = {
 			}
 		},
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 			// Convert MoveOperation properties into a range.
@@ -185,6 +190,26 @@ const ot = {
 		}
 	},
 
+	RootAttributeOperation: {
+		InsertOperation: doNotUpdate,
+
+		AttributeOperation: doNotUpdate,
+
+		// Transforms RootAttributeOperation `a` by RootAttributeOperation `b`. Accepts a flag stating whether `a` is more important
+		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+		RootAttributeOperation( a, b, isStrong ) {
+			if ( a.root === b.root && a.key === b.key ) {
+				if ( ( a.newValue !== b.newValue && !isStrong ) || a.newValue === b.newValue ) {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
+			}
+
+			return [ a.clone() ];
+		},
+
+		MoveOperation: doNotUpdate
+	},
+
 	MoveOperation: {
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
@@ -205,6 +230,8 @@ const ot = {
 
 		AttributeOperation: doNotUpdate,
 
+		RootAttributeOperation: doNotUpdate,
+
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -315,6 +342,8 @@ function transform( a, b, isStrong ) {
 		group = ot.InsertOperation;
 	} else if ( a instanceof AttributeOperation ) {
 		group = ot.AttributeOperation;
+	} else if ( a instanceof RootAttributeOperation ) {
+		group = ot.RootAttributeOperation;
 	} else if ( a instanceof MoveOperation ) {
 		group = ot.MoveOperation;
 	} else {
@@ -326,6 +355,8 @@ function transform( a, b, isStrong ) {
 			algorithm = group.InsertOperation;
 		} else if ( b instanceof AttributeOperation ) {
 			algorithm = group.AttributeOperation;
+		} else if ( b instanceof RootAttributeOperation ) {
+			algorithm = group.RootAttributeOperation;
 		} else if ( b instanceof MoveOperation ) {
 			algorithm = group.MoveOperation;
 		} else {

+ 200 - 0
packages/ckeditor5-engine/tests/treemodel/operation/rootattributeoperation.js

@@ -0,0 +1,200 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import Document from '/ckeditor5/core/treemodel/document.js';
+import RootAttributeOperation from '/ckeditor5/core/treemodel/operation/rootattributeoperation.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+describe( 'RootAttributeOperation', () => {
+	let doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+	} );
+
+	it( 'should have proper type', () => {
+		const op = new RootAttributeOperation(
+			root,
+			'isNew',
+			null,
+			true,
+			doc.version
+		);
+
+		expect( op.type ).to.equal( 'rootattribute' );
+	} );
+
+	it( 'should add attribute on the root element', () => {
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'isNew',
+				null,
+				true,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.hasAttribute( 'isNew' ) ).to.be.true;
+	} );
+
+	it( 'should change attribute on the root element', () => {
+		root.setAttribute( 'isNew', false );
+
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'isNew',
+				false,
+				true,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.getAttribute( 'isNew' ) ).to.be.true;
+	} );
+
+	it( 'should remove attribute from the root element', () => {
+		root.setAttribute( 'x', true );
+
+		doc.applyOperation(
+			new RootAttributeOperation(
+				root,
+				'x',
+				true,
+				null,
+				doc.version
+			)
+		);
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.hasAttribute( 'x' ) ).to.be.false;
+	} );
+
+	it( 'should create a RootAttributeOperation as a reverse', () => {
+		let operation = new RootAttributeOperation( root, 'x', 'old', 'new', doc.version );
+		let reverse = operation.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( RootAttributeOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.root ).to.equal( root );
+		expect( reverse.key ).to.equal( 'x' );
+		expect( reverse.oldValue ).to.equal( 'new' );
+		expect( reverse.newValue ).to.equal( 'old' );
+	} );
+
+	it( 'should undo adding attribute by applying reverse operation', () => {
+		let operation = new RootAttributeOperation(
+			root,
+			'isNew',
+			null,
+			true,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.hasAttribute( 'x' ) ).to.be.false;
+	} );
+
+	it( 'should undo changing attribute by applying reverse operation', () => {
+		root.setAttribute( 'isNew', false );
+
+		let operation = new RootAttributeOperation(
+			root,
+			'isNew',
+			false,
+			true,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.getAttribute( 'isNew' ) ).to.be.false;
+	} );
+
+	it( 'should undo remove attribute by applying reverse operation', () => {
+		root.setAttribute( 'foo', true );
+
+		let operation = new RootAttributeOperation(
+			root,
+			'foo',
+			true,
+			null,
+			doc.version
+		);
+
+		let reverse = operation.getReversed();
+
+		doc.applyOperation( operation );
+		doc.applyOperation( reverse );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( root.getAttribute( 'foo' ) ).to.be.true;
+	} );
+
+	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
+		expect( () => {
+			doc.applyOperation(
+				new RootAttributeOperation(
+					root,
+					'foo',
+					true,
+					null,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-rootattribute-no-attr-to-remove/ );
+	} );
+
+	it( 'should throw an error when one try to insert and the attribute already exists', () => {
+		root.setAttribute( 'x', 1 );
+
+		expect( () => {
+			doc.applyOperation(
+				new RootAttributeOperation(
+					root,
+					'x',
+					null,
+					2,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-rootattribute-attr-exists/ );
+	} );
+
+	it( 'should create a RootAttributeOperation with the same parameters when cloned', () => {
+		let baseVersion = doc.version;
+
+		let op = new RootAttributeOperation( root, 'foo', 'old', 'new', baseVersion );
+
+		let clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( RootAttributeOperation );
+		expect( clone.root ).to.equal( root );
+		expect( clone.key ).to.equal( 'foo' );
+		expect( clone.oldValue ).to.equal( 'old' );
+		expect( clone.newValue ).to.equal( 'new' );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
+} );

+ 230 - 0
packages/ckeditor5-engine/tests/treemodel/operation/transform.js

@@ -14,6 +14,7 @@ import Range from '/ckeditor5/core/treemodel/range.js';
 import transform from '/ckeditor5/core/treemodel/operation/transform.js';
 import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+import RootAttributeOperation from '/ckeditor5/core/treemodel/operation/rootattributeoperation.js';
 import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
 import NoOperation from '/ckeditor5/core/treemodel/operation/nooperation.js';
 
@@ -181,6 +182,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'range and target are different than insert position: no position update', () => {
 				let transformBy = new MoveOperation(
@@ -793,6 +811,23 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			describe( 'by RootAttributeOperation', () => {
+				it( 'no operation update', () => {
+					let transformBy = new RootAttributeOperation(
+						root,
+						'foo',
+						null,
+						'bar',
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
 			describe( 'by MoveOperation', () => {
 				it( 'range and target are different than change range: no operation update', () => {
 					let transformBy = new MoveOperation(
@@ -1297,6 +1332,167 @@ describe( 'transform', () => {
 		} );
 	} );
 
+	describe( 'RootAttributeOperation', () => {
+		let diffRoot = new RootElement( null );
+
+		beforeEach( () => {
+			expected = {
+				type: RootAttributeOperation,
+				key: 'foo',
+				oldValue: 'abc',
+				newValue: 'bar',
+				baseVersion: baseVersion + 1
+			};
+
+			op = new RootAttributeOperation( root, 'foo', 'abc', 'bar', baseVersion );
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0 ] ),
+					'a',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by AttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new AttributeOperation(
+					new Range(
+						new Position( root, [ 0 ] ),
+						new Position( root, [ 1 ] )
+					),
+					'foo',
+					'bar',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RootAttributeOperation', () => {
+			it( 'changes different root: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					diffRoot,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'changes different key: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'abc',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'sets same value for same key: convert to NoOperation', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'sets different value for same key on same root and is important: no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'sets different value for same key on same root and is less important: convert to NoOperation', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					'abc',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0 ] ),
+					2,
+					new Position( root, [ 1 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by NoOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new NoOperation( baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+
 	describe( 'MoveOperation', () => {
 		let sourcePosition, targetPosition, rangeEnd, howMany;
 
@@ -1550,6 +1746,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'range and target different than transforming range and target: no operation update', () => {
 				let transformBy = new MoveOperation(
@@ -2378,6 +2591,23 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by MoveOperation', () => {
 			it( 'no operation update', () => {
 				let transformBy = new MoveOperation(