Răsfoiți Sursa

Merge branch 'master' into t/1528

Piotr Jasiun 7 ani în urmă
părinte
comite
601343fa10

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

@@ -38,7 +38,7 @@
     "@ckeditor/ckeditor5-typing": "^11.0.0",
     "@ckeditor/ckeditor5-undo": "^10.0.2",
     "@ckeditor/ckeditor5-widget": "^10.2.0",
-    "eslint": "^4.15.0",
+    "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",
     "lint-staged": "^7.0.0"

+ 27 - 5
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -25,6 +25,10 @@ import MoveOperation from '../model/operation/moveoperation';
 import NoOperation from '../model/operation/nooperation';
 import RenameOperation from '../model/operation/renameoperation';
 import RootAttributeOperation from '../model/operation/rootattributeoperation';
+import WrapOperation from '../model/operation/wrapoperation';
+import UnwrapOperation from '../model/operation/unwrapoperation';
+import SplitOperation from '../model/operation/splitoperation';
+import MergeOperation from '../model/operation/mergeoperation';
 import Model from '../model/model';
 import ModelDocument from '../model/document';
 import ModelDocumentFragment from '../model/documentfragment';
@@ -330,8 +334,7 @@ function enableLoggingTools() {
 	sandbox.mock( MoveOperation.prototype, 'toString', function() {
 		const range = ModelRange.createFromPositionAndShift( this.sourcePosition, this.howMany );
 
-		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
-			`${ range } -> ${ this.targetPosition }${ this.isSticky ? ' (sticky)' : '' }`;
+		return getClassName( this ) + `( ${ this.baseVersion } ): ${ range } -> ${ this.targetPosition }`;
 	} );
 
 	sandbox.mock( NoOperation.prototype, 'toString', function() {
@@ -348,6 +351,27 @@ function enableLoggingTools() {
 			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.root.rootName }`;
 	} );
 
+	sandbox.mock( MergeOperation.prototype, 'toString', function() {
+		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
+			`${ this.sourcePosition } -> ${ this.targetPosition } ( ${ this.howMany } ), ${ this.graveyardPosition }`;
+	} );
+
+	sandbox.mock( SplitOperation.prototype, 'toString', function() {
+		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
+			`${ this.position } ( ${ this.howMany } )${ this.graveyardPosition ? ', ' + this.graveyardPosition : '' }`;
+	} );
+
+	sandbox.mock( WrapOperation.prototype, 'toString', function() {
+		const range = ModelRange.createFromPositionAndShift( this.position, this.howMany );
+
+		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
+			`${ range } with ${ this.element ? this.element : this.graveyardPosition }`;
+	} );
+
+	sandbox.mock( UnwrapOperation.prototype, 'toString', function() {
+		return getClassName( this ) + `( ${ this.baseVersion } ): ${ this.position } ( ${ this.howMany } ), ${ this.graveyardPosition }`;
+	} );
+
 	sandbox.mock( ViewText.prototype, 'toString', function() {
 		return `#${ this.data }`;
 	} );
@@ -547,9 +571,7 @@ function dumpTrees( document, version ) {
 // @param {module:engine/model/operation/operation~Operation}
 // @returns {String} Class name.
 function getClassName( obj ) {
-	const path = obj.constructor.className.split( '.' );
-
-	return path[ path.length - 1 ];
+	return obj.constructor.className;
 }
 
 // Helper function, converts a map to the {"key1":"value1","key2":"value2"} format.

+ 2 - 0
packages/ckeditor5-engine/src/model/documentselection.js

@@ -45,6 +45,8 @@ const storePrefix = 'selection:';
  * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
  * If you need to represent a selection in document fragment,
  * use {@link module:engine/model/selection~Selection Selection class} instead.
+ *
+ * @mixes module:utils/emittermixin~EmitterMixin
  */
 export default class DocumentSelection {
 	/**

+ 97 - 10
packages/ckeditor5-engine/src/model/model.js

@@ -207,10 +207,15 @@ export default class Model {
 	}
 
 	/**
-	 * {@link module:utils/observablemixin~ObservableMixin#decorate Decorated} function to apply
-	 * {@link module:engine/model/operation/operation~Operation operations} on the model.
+	 * {@link module:utils/observablemixin~ObservableMixin#decorate Decorated} function for applying
+	 * {@link module:engine/model/operation/operation~Operation operations} to the model.
 	 *
-	 * @param {module:engine/model/operation/operation~Operation} operation Operation to apply
+	 * This is a low-level way of changing the model. It is exposed for very specific use cases (like the undo feature).
+	 * Normally, to modify the model, you will want to use {@link module:engine/model/writer~Writer `Writer`}.
+	 * See also {@glink framework/guides/architecture/editing-engine#changing-the-model Changing the model} section
+	 * of the {@glink framework/guides/architecture/editing-engine Editing architecture} guide.
+	 *
+	 * @param {module:engine/model/operation/operation~Operation} operation The operation to apply.
 	 */
 	applyOperation( operation ) {
 		operation._execute();
@@ -220,6 +225,79 @@ export default class Model {
 	 * Inserts content into the editor (specified selection) as one would expect the paste
 	 * functionality to work.
 	 *
+	 * This is a high-level method. It takes the {@link #schema schema} into consideration when inserting
+	 * the content, clears the given selection's content before inserting nodes and moves the selection
+	 * to its target position at the end of the process.
+	 * It can split elements, merge them, wrap bare text nodes in paragraphs, etc. – just like the
+	 * pasting feature should do.
+	 *
+	 * For lower-level methods see {@link module:engine/model/writer~Writer `Writer`}.
+	 *
+	 * This method, unlike {@link module:engine/model/writer~Writer `Writer`}'s methods, does not have to be used
+	 * inside a {@link #change `change()` block}.
+	 *
+	 * # Conversion and schema
+	 *
+	 * Inserting elements and text nodes into the model is not enough to make CKEditor 5 render that content
+	 * to the user. CKEditor 5 implements a model-view-controller architecture and what `model.insertContent()` does
+	 * is only adding nodes to the model. Additionally, you need to define
+	 * {@glink framework/guides/architecture/editing-engine#conversion converters} between the model and view
+	 * and define those nodes in the {@glink framework/guides/architecture/editing-engine#schema schema}.
+	 *
+	 * So, while this method may seem similar to CKEditor 4's `editor.insertHtml()` (in fact, both methods
+	 * are used for paste-like content insertion), CKEditor 5's method cannot be use to insert arbitrary HTML
+	 * unless converters are defined for all elements and attributes in that HTML.
+	 *
+	 * # Examples
+	 *
+	 * Using `insertContent()` with a manually created model structure:
+	 *
+	 *		// Let's create a document fragment containing such a content:
+	 *		//
+	 *		// <paragrap>foo</paragraph>
+	 *		// <blockQuote>
+	 *		//    <paragraph>bar</paragraph>
+	 *		// </blockQuote>
+	 *		const docFrag = editor.model.change( writer => {
+	 *			const p1 = writer.createElement( 'paragraph' );
+	 *			const p2 = writer.createElement( 'paragraph' );
+	 *			const blockQuote = writer.createElement( 'blockQuote' );
+	 *			const docFrag = writer.createDocumentFragment();
+	 *
+	 *			writer.append( p1, docFrag );
+	 *			writer.append( blockQuote, docFrag );
+	 *			writer.append( p2, blockQuote );
+	 *			writer.insertText( 'foo', p1 );
+	 *			writer.insertText( 'bar', p2 );
+	 *
+	 *			return docFrag;
+	 *		} );
+	 *
+	 *		// insertContent() doesn't have to be used in a change() block. It can, though,
+	 *		// so this code could be moved to the callback defined above.
+	 *		editor.model.insertContent( docFrag, editor.model.document.selection );
+	 *
+	 * Using `insertContent()` with HTML string converted to a model document fragment (similar to the pasting mechanism):
+	 *
+	 *		// You can create your own HtmlDataProcessor instance or use editor.data.processor
+	 *		// if you haven't overriden the default one (which is HtmlDataProcessor instance).
+	 *		const htmlDP = new HtmlDataProcessor();
+	 *
+	 *		// Convert an HTML string to a view document fragment.
+	 *		const viewFragment = htmlDP.toView( htmlString );
+	 *
+	 *		// Convert a view document fragment to a model document fragment
+	 *		// in the context of $root. This conversion takes schema into
+	 *		// the account so if e.g. the view document fragment contained a bare text node
+	 *		// then that text node cannot be a child of $root, so it will be automatically
+	 *		// wrapped with a <paragraph>. You can define the context yourself (in the 2nd parameter),
+	 *		// and e.g. convert the content like it would happen in a <paragraph>.
+	 *		// Note: the clipboard feature uses a custom context called $clipboardHolder
+	 *		// which has a loosened schema.
+	 *		const modelFragment = editor.data.toModel( viewFragment );
+	 *
+	 *		editor.model.insertContent( modelFragment, editor.model.document.selection );
+	 *
 	 * @fires insertContent
 	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
 	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
@@ -247,10 +325,10 @@ export default class Model {
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.leaveUnmerged=false] Whether to merge elements after removing the content of the selection.
 	 *
-	 * For example `<heading>x[x</heading><paragraph>y]y</paragraph>` will become:
+	 * For example `<heading1>x[x</heading1><paragraph>y]y</paragraph>` will become:
 	 *
-	 * * `<heading>x^y</heading>` with the option disabled (`leaveUnmerged == false`)
-	 * * `<heading>x^</heading><paragraph>y</paragraph>` with enabled (`leaveUnmerged == true`).
+	 * * `<heading1>x^y</heading1>` with the option disabled (`leaveUnmerged == false`)
+	 * * `<heading1>x^</heading1><paragraph>y</paragraph>` with enabled (`leaveUnmerged == true`).
 	 *
 	 * Note: {@link module:engine/model/schema~Schema#isObject object} and {@link module:engine/model/schema~Schema#isLimit limit}
 	 * elements will not be merged.
@@ -258,10 +336,10 @@ export default class Model {
 	 * @param {Boolean} [options.doNotResetEntireContent=false] Whether to skip replacing the entire content with a
 	 * paragraph when the entire content was selected.
 	 *
-	 * For example `<heading>[x</heading><paragraph>y]</paragraph>` will become:
+	 * For example `<heading1>[x</heading1><paragraph>y]</paragraph>` will become:
 	 *
 	 * * `<paragraph>^</paragraph>` with the option disabled (`doNotResetEntireContent == false`)
-	 * * `<heading>^</heading>` with enabled (`doNotResetEntireContent == true`)
+	 * * `<heading1>^</heading1>` with enabled (`doNotResetEntireContent == true`)
 	 */
 	deleteContent( selection, options ) {
 		deleteContent( this, selection, options );
@@ -306,13 +384,22 @@ export default class Model {
 	 * For example, for the following selection:
 	 *
 	 * ```html
-	 * <p>x</p><quote><p>y</p><h>fir[st</h></quote><p>se]cond</p><p>z</p>
+	 * <paragraph>x</paragraph>
+	 * <blockQuote>
+	 *	<paragraph>y</paragraph>
+	 *	<heading1>fir[st</heading1>
+	 * </blockQuote>
+	 * <paragraph>se]cond</paragraph>
+	 * <paragraph>z</paragraph>
 	 * ```
 	 *
 	 * It will return a document fragment with such a content:
 	 *
 	 * ```html
-	 * <quote><h>st</h></quote><p>se</p>
+	 * <blockQuote>
+	 *	<heading1>st</heading1>
+	 * </blockQuote>
+	 * <paragraph>se</paragraph>
 	 * ```
 	 *
 	 * @fires getSelectedContent

+ 1 - 0
packages/ckeditor5-engine/src/model/operation/insertoperation.js

@@ -40,6 +40,7 @@ export default class InsertOperation extends Operation {
 		 * @member {module:engine/model/position~Position} module:engine/model/operation/insertoperation~InsertOperation#position
 		 */
 		this.position = Position.createFromPosition( position );
+		this.position.stickiness = 'toNone';
 
 		/**
 		 * List of nodes to insert.

+ 1 - 0
packages/ckeditor5-engine/src/model/operation/moveoperation.js

@@ -41,6 +41,7 @@ export default class MoveOperation extends Operation {
 		 * @member {module:engine/model/position~Position} module:engine/model/operation/moveoperation~MoveOperation#sourcePosition
 		 */
 		this.sourcePosition = Position.createFromPosition( sourcePosition );
+		// `'toNext'` because `sourcePosition` is a bit like a start of the moved range.
 		this.sourcePosition.stickiness = 'toNext';
 
 		/**

+ 1 - 1
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -1126,7 +1126,7 @@ setTransformation( MarkerOperation, InsertOperation, ( a, b ) => {
 setTransformation( MarkerOperation, MarkerOperation, ( a, b, context ) => {
 	if ( a.name == b.name ) {
 		if ( context.aIsStrong ) {
-			a.oldRange = Range.createFromRange( b.newRange );
+			a.oldRange = b.newRange ? Range.createFromRange( b.newRange ) : null;
 		} else {
 			return [ new NoOperation( 0 ) ];
 		}

+ 1 - 0
packages/ckeditor5-engine/src/model/operation/wrapoperation.js

@@ -43,6 +43,7 @@ export default class WrapOperation extends Operation {
 		 * @member {module:engine/model/position~Position} module:engine/model/operation/wrapoperation~WrapOperation#position
 		 */
 		this.position = Position.createFromPosition( position );
+		// `'toNext'` because `position` is a bit like a start of the wrapped range.
 		this.position.stickiness = 'toNext';
 
 		/**

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

@@ -918,12 +918,14 @@ export default class Range {
 
 		// 5. Ranges should be checked and glued starting from the range that is closest to the reference range.
 		// Since ranges are sorted, start with the range with index that is closest to reference range index.
-		for ( let i = refIndex - 1; i >= 0; i++ ) {
-			if ( ranges[ i ].end.isEqual( result.start ) ) {
-				result.start = Position.createFromPosition( ranges[ i ].start );
-			} else {
-				// If ranges are not starting/ending at the same position there is no point in looking further.
-				break;
+		if ( refIndex > 0 ) {
+			for ( let i = refIndex - 1; true; i++ ) {
+				if ( ranges[ i ].end.isEqual( result.start ) ) {
+					result.start = Position.createFromPosition( ranges[ i ].start );
+				} else {
+					// If ranges are not starting/ending at the same position there is no point in looking further.
+					break;
+				}
 			}
 		}
 

+ 37 - 28
packages/ckeditor5-engine/src/model/selection.js

@@ -17,19 +17,23 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
- * `Selection` is a group of {@link module:engine/model/range~Range ranges} which has a direction specified by
- * {@link module:engine/model/selection~Selection#anchor anchor} and {@link module:engine/model/selection~Selection#focus focus}.
- * Additionally, `Selection` may have it's own attributes.
+ * Selection is a set of {@link module:engine/model/range~Range ranges}. It has a direction specified by its
+ * {@link module:engine/model/selection~Selection#anchor anchor} and {@link module:engine/model/selection~Selection#focus focus}
+ * (it can be {@link module:engine/model/selection~Selection#isBackward forward or backward}).
+ * Additionally, selection may have its own attributes (think – whether text typed in in this selection
+ * should have those attributes – e.g. whether you type a bolded text).
  *
- * @mixes {module:utils/emittermixin~EmitterMixin}
+ * @mixes module:utils/emittermixin~EmitterMixin
  */
 export default class Selection {
 	/**
-	 * Creates new selection instance on the given
-	 * {@link module:engine/model/selection~Selection selection}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/element~Element element}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/range~Range range}, an iterable of {@link module:engine/model/range~Range ranges}
-	 * or creates an empty selection if no arguments passed.
+	 * Creates a new selection instance
+	 * based on the given {@link module:engine/model/selection~Selection selection},
+	 * or based on the given {@link module:engine/model/range~Range range},
+	 * or based on an iterable collection of {@link module:engine/model/range~Range ranges}
+	 * or at the given {@link module:engine/model/position~Position position},
+	 * or on the given {@link module:engine/model/element~Element element},
+	 * or creates an empty selection if no arguments were passed.
 	 *
 	 * 		// Creates empty selection without ranges.
 	 *		const selection = new Selection();
@@ -68,7 +72,7 @@ export default class Selection {
 	 * 		// just after the item.
 	 *		const selection = new Selection( paragraph, 'on' );
 	 *
-	 * `Selection`'s constructor allow passing additional options (`backward`) as the last argument.
+	 * Selection's constructor allow passing additional options (`'backward'`) as the last argument.
 	 *
 	 * 		// Creates backward selection.
 	 *		const selection = new Selection( range, { backward: true } );
@@ -111,12 +115,17 @@ export default class Selection {
 	}
 
 	/**
-	 * Selection anchor. Anchor may be described as a position where the most recent part of the selection starts.
-	 * Together with {@link #focus} they define the direction of selection, which is important
-	 * when expanding/shrinking selection. Anchor is always {@link module:engine/model/range~Range#start start} or
-	 * {@link module:engine/model/range~Range#end end} position of the most recently added range.
+	 * Selection anchor. Anchor is the position from which the selection was started. If a user is making a selection
+	 * by dragging the mouse, the anchor is where the user pressed the mouse button (the beggining of the selection).
+	 *
+	 * Anchor and {@link #focus} define the direction of the selection, which is important
+	 * when expanding/shrinking selection. The focus moves, while the anchor should remain in the same place.
+	 *
+	 * Anchor is always set to the {@link module:engine/model/range~Range#start start} or
+	 * {@link module:engine/model/range~Range#end end} position of the last of selection's ranges. Whether it is
+	 * the `start` or `end` depends on the specified `options.backward`. See the {@link #setTo `setTo()`} method.
 	 *
-	 * Is set to `null` if there are no ranges in selection.
+	 * May be set to `null` if there are no ranges in the selection.
 	 *
 	 * @see #focus
 	 * @readonly
@@ -133,9 +142,10 @@ export default class Selection {
 	}
 
 	/**
-	 * Selection focus. Focus is a position where the selection ends.
+	 * Selection focus. Focus is the position where the selection ends. If a user is making a selection
+	 * by dragging the mouse, the focus is where the mouse cursor is.
 	 *
-	 * Is set to `null` if there are no ranges in selection.
+	 * May be set to `null` if there are no ranges in the selection.
 	 *
 	 * @see #anchor
 	 * @readonly
@@ -152,8 +162,8 @@ export default class Selection {
 	}
 
 	/**
-	 * Returns whether the selection is collapsed. Selection is collapsed when there is exactly one range which is
-	 * collapsed.
+	 * Whether the selection is collapsed. Selection is collapsed when there is exactly one range in it
+	 * and it is collapsed.
 	 *
 	 * @readonly
 	 * @type {Boolean}
@@ -169,7 +179,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Returns number of ranges in selection.
+	 * Returns the number of ranges in the selection.
 	 *
 	 * @readonly
 	 * @type {Number}
@@ -179,8 +189,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Specifies whether the {@link #focus}
-	 * precedes {@link #anchor}.
+	 * Specifies whether the selection's {@link #focus} precedes the selection's {@link #anchor}.
 	 *
 	 * @readonly
 	 * @type {Boolean}
@@ -190,8 +199,8 @@ export default class Selection {
 	}
 
 	/**
-	 * Checks whether this selection is equal to given selection. Selections are equal if they have same directions,
-	 * same number of ranges and all ranges from one selection equal to a range from other selection.
+	 * Checks whether this selection is equal to the given selection. Selections are equal if they have the same directions,
+	 * the same number of ranges and all ranges from one selection equal to ranges from the another selection.
 	 *
 	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} otherSelection
 	 * Selection to compare with.
@@ -227,7 +236,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Returns an iterable that iterates over copies of selection ranges.
+	 * Returns an iterable object that iterates over copies of selection ranges.
 	 *
 	 * @returns {Iterable.<module:engine/model/range~Range>}
 	 */
@@ -703,14 +712,14 @@ export default class Selection {
 		for ( let i = 0; i < this._ranges.length; i++ ) {
 			if ( range.isIntersecting( this._ranges[ i ] ) ) {
 				/**
-				 * Trying to add a range that intersects with another range from selection.
+				 * Trying to add a range that intersects with another range in the selection.
 				 *
 				 * @error model-selection-range-intersects
 				 * @param {module:engine/model/range~Range} addedRange Range that was added to the selection.
-				 * @param {module:engine/model/range~Range} intersectingRange Range from selection that intersects with `addedRange`.
+				 * @param {module:engine/model/range~Range} intersectingRange Range in the selection that intersects with `addedRange`.
 				 */
 				throw new CKEditorError(
-					'model-selection-range-intersects: Trying to add a range that intersects with another range from selection.',
+					'model-selection-range-intersects: Trying to add a range that intersects with another range in the selection.',
 					{ addedRange: range, intersectingRange: this._ranges[ i ] }
 				);
 			}

+ 3 - 0
packages/ckeditor5-engine/src/model/writer.js

@@ -145,6 +145,9 @@ export default class Writer {
 	 * If you want to move {@link module:engine/model/range~Range range} instead of an
 	 * {@link module:engine/model/item~Item item} use {@link module:engine/model/writer~Writer#move `Writer#move()`}.
 	 *
+	 * **Note:** For a paste-like content insertion mechanism see
+	 * {@link module:engine/model/model~Model#insertContent `model.insertContent()`}.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/documentfragment~DocumentFragment} item Item or document
 	 * fragment to insert.
 	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition

+ 2 - 2
packages/ckeditor5-engine/src/view/element.js

@@ -840,8 +840,7 @@ function parseInlineStyles( stylesMap, stylesString ) {
 
 					break;
 
-				// eslint-disable-next-line no-case-declarations
-				case ';':
+				case ';': {
 					// Property value just ended.
 					// Use previously stored property value start to obtain property value.
 					const propertyValue = stylesString.substr( propertyValueStart, i - propertyValueStart );
@@ -857,6 +856,7 @@ function parseInlineStyles( stylesMap, stylesString ) {
 					propertyNameStart = i + 1;
 
 					break;
+				}
 			}
 		} else if ( char === quoteType ) {
 			// If a quote char is found and it is a closing quote, mark this fact by `null`-ing `quoteType`.

+ 101 - 10
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -21,6 +21,10 @@ import MoveOperation from '../../src/model/operation/moveoperation';
 import NoOperation from '../../src/model/operation/nooperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import RootAttributeOperation from '../../src/model/operation/rootattributeoperation';
+import MergeOperation from '../../src/model/operation/mergeoperation';
+import SplitOperation from '../../src/model/operation/splitoperation';
+import WrapOperation from '../../src/model/operation/wrapoperation';
+import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 import Model from '../../src/model/model';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 
@@ -311,16 +315,6 @@ describe( 'debug tools', () => {
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
 			} );
 
-			it( 'MoveOperation sticky', () => {
-				const op = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( modelRoot, 6 ), 0 );
-				op.isSticky = true;
-
-				expect( op.toString() ).to.equal( 'MoveOperation( 0 ): main [ 1 ] - [ 3 ] -> main [ 6 ] (sticky)' );
-
-				op.log();
-				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
-			} );
-
 			it( 'NoOperation', () => {
 				const op = new NoOperation( 0 );
 
@@ -347,6 +341,103 @@ describe( 'debug tools', () => {
 				op.log();
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
 			} );
+
+			it( 'MergeOperation', () => {
+				const op = new MergeOperation(
+					new ModelPosition( modelRoot, [ 1, 0 ] ),
+					2,
+					new ModelPosition( modelRoot, [ 0, 2 ] ),
+					new ModelPosition( modelDoc.graveyard, [ 0 ] ),
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'MergeOperation( 0 ): main [ 1, 0 ] -> main [ 0, 2 ] ( 2 ), $graveyard [ 0 ]'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'SplitOperation without graveyard position', () => {
+				const op = new SplitOperation(
+					new ModelPosition( modelRoot, [ 1, 4 ] ),
+					6,
+					null,
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'SplitOperation( 0 ): main [ 1, 4 ] ( 6 )'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'SplitOperation with graveyard position', () => {
+				const op = new SplitOperation(
+					new ModelPosition( modelRoot, [ 1, 4 ] ),
+					6,
+					new ModelPosition( modelDoc.graveyard, [ 0 ] ),
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'SplitOperation( 0 ): main [ 1, 4 ] ( 6 ), $graveyard [ 0 ]'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'WrapOperation with element', () => {
+				const op = new WrapOperation(
+					new ModelPosition( modelRoot, [ 3 ] ),
+					2,
+					new ModelElement( 'blockQuote' ),
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'WrapOperation( 0 ): main [ 3 ] - [ 5 ] with <blockQuote>'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'WrapOperation with graveyard position', () => {
+				const op = new WrapOperation(
+					new ModelPosition( modelRoot, [ 3 ] ),
+					2,
+					new ModelPosition( modelDoc.graveyard, [ 0 ] ),
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'WrapOperation( 0 ): main [ 3 ] - [ 5 ] with $graveyard [ 0 ]'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'UnwrapOperation', () => {
+				const op = new UnwrapOperation(
+					new ModelPosition( modelRoot, [ 1, 0 ] ),
+					2,
+					new ModelPosition( modelDoc.graveyard, [ 0 ] ),
+					0
+				);
+
+				expect( op.toString() ).to.equal(
+					'UnwrapOperation( 0 ): main [ 1, 0 ] ( 2 ), $graveyard [ 0 ]'
+				);
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
 		} );
 
 		it( 'for applied operations', () => {

+ 13 - 0
packages/ckeditor5-engine/tests/model/operation/insertoperation.js

@@ -31,6 +31,19 @@ describe( 'InsertOperation', () => {
 		expect( op.type ).to.equal( 'insert' );
 	} );
 
+	it( 'should have proper position stickiness', () => {
+		const pos = new Position( root, [ 0 ] );
+		pos.stickiness = 'toNext';
+
+		const op = new InsertOperation(
+			new Position( root, [ 0 ] ),
+			new Text( 'x' ),
+			doc.version
+		);
+
+		expect( op.position.stickiness ).to.equal( 'toNone' );
+	} );
+
 	it( 'should insert text node', () => {
 		model.applyOperation(
 			new InsertOperation(

+ 20 - 0
packages/ckeditor5-engine/tests/model/operation/transform/marker.js

@@ -82,6 +82,26 @@ describe( 'transform', () => {
 				);
 			} );
 
+			it( 'change marker vs remove marker', () => {
+				john.setData( '<paragraph>F[o]o</paragraph>' );
+				kate.setData( '<paragraph>[]Foo</paragraph>' );
+
+				john.setMarker( 'm1' );
+
+				syncClients();
+
+				john.setSelection( [ 0, 0 ], [ 0, 1 ] );
+				john.setMarker( 'm1' );
+				kate.removeMarker( 'm1' );
+
+				syncClients();
+				expectClients(
+					'<paragraph>' +
+						'<m1:start></m1:start>F<m1:end></m1:end>oo' +
+					'</paragraph>'
+				);
+			} );
+
 			it( 'then wrap and split', () => {
 				john.setData( '<paragraph>[Foo] Bar</paragraph>' );
 				kate.setData( '<paragraph>Fo[o Bar]</paragraph>' );