Explorar el Código

Merge pull request #1050 from ckeditor/t/1049

Fixed: Selection will not be placed in UI elements. Closes #1049.
Piotrek Koszuliński hace 8 años
padre
commit
f9deecee3c

+ 2 - 0
packages/ckeditor5-engine/src/view/document.js

@@ -12,6 +12,7 @@ import Renderer from './renderer';
 import DomConverter from './domconverter';
 import DomConverter from './domconverter';
 import RootEditableElement from './rooteditableelement';
 import RootEditableElement from './rooteditableelement';
 import { injectQuirksHandling } from './filler';
 import { injectQuirksHandling } from './filler';
+import { injectUiElementHandling } from './uielement';
 import log from '@ckeditor/ckeditor5-utils/src/log';
 import log from '@ckeditor/ckeditor5-utils/src/log';
 import MutationObserver from './observer/mutationobserver';
 import MutationObserver from './observer/mutationobserver';
 import SelectionObserver from './observer/selectionobserver';
 import SelectionObserver from './observer/selectionobserver';
@@ -126,6 +127,7 @@ export default class Document {
 		this.addObserver( FakeSelectionObserver );
 		this.addObserver( FakeSelectionObserver );
 
 
 		injectQuirksHandling( this );
 		injectQuirksHandling( this );
+		injectUiElementHandling( this );
 
 
 		this.decorate( 'render' );
 		this.decorate( 'render' );
 	}
 	}

+ 46 - 0
packages/ckeditor5-engine/src/view/domconverter.js

@@ -838,6 +838,52 @@ export default class DomConverter {
 		return null;
 		return null;
 	}
 	}
 
 
+	/**
+	 * Checks if given selection's boundaries are at correct places.
+	 *
+	 * The following places are considered as incorrect for selection boundaries:
+	 * * before or in the middle of the inline filler sequence,
+	 * * inside the DOM element which represents {@link module:engine/view/uielement~UIElement a view ui element}.
+	 *
+	 * @param {Selection} domSelection DOM Selection object to be checked.
+	 * @returns {Boolean} `true` if the given selection is at a correct place, `false` otherwise.
+	 */
+	isDomSelectionCorrect( domSelection ) {
+		return this._isDomSelectionPositionCorrect( domSelection.anchorNode, domSelection.anchorOffset ) &&
+			this._isDomSelectionPositionCorrect( domSelection.focusNode, domSelection.focusOffset );
+	}
+
+	/**
+	 * Checks if the given DOM position is a correct place for selection boundary. See {@link ~isDomSelectionCorrect}.
+	 *
+	 * @private
+	 * @param {Element} domParent Position parent.
+	 * @param {Number} offset Position offset.
+	 * @returns {Boolean} `true` if given position is at a correct place for selection boundary, `false` otherwise.
+	 */
+	_isDomSelectionPositionCorrect( domParent, offset ) {
+		// If selection is before or in the middle of inline filler string, it is incorrect.
+		if ( this.isText( domParent ) && startsWithFiller( domParent ) && offset < INLINE_FILLER_LENGTH ) {
+			// Selection in a text node, at wrong position (before or in the middle of filler).
+			return false;
+		}
+
+		if ( this.isElement( domParent ) && startsWithFiller( domParent.childNodes[ offset ] ) ) {
+			// Selection in an element node, before filler text node.
+			return false;
+		}
+
+		const viewParent = this.mapDomToView( domParent );
+
+		// If selection is in `view.UIElement`, it is incorrect. Note that `mapDomToView()` returns `view.UIElement`
+		// also for any dom element that is inside the view ui element (so we don't need to perform any additional checks).
+		if ( viewParent && viewParent.is( 'uiElement' ) ) {
+			return false;
+		}
+
+		return true;
+	}
+
 	/**
 	/**
 	 * Takes text data from given {@link module:engine/view/text~Text#data} and processes it so it is correctly displayed in DOM.
 	 * Takes text data from given {@link module:engine/view/text~Text#data} and processes it so it is correctly displayed in DOM.
 	 *
 	 *

+ 15 - 0
packages/ckeditor5-engine/src/view/node.js

@@ -10,6 +10,7 @@
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 
 
 /**
 /**
  * Abstract tree view node class.
  * Abstract tree view node class.
@@ -181,6 +182,20 @@ export default class Node {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the parent property removed.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		delete json.parent;
+
+		return json;
+	}
+
 	/**
 	/**
 	 * Clones this node.
 	 * Clones this node.
 	 *
 	 *

+ 21 - 15
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -148,7 +148,7 @@ export default class SelectionObserver extends Observer {
 		const domSelection = domDocument.defaultView.getSelection();
 		const domSelection = domDocument.defaultView.getSelection();
 		const newViewSelection = this.domConverter.domSelectionToView( domSelection );
 		const newViewSelection = this.domConverter.domSelectionToView( domSelection );
 
 
-		if ( this.selection.isEqual( newViewSelection ) ) {
+		if ( this.selection.isEqual( newViewSelection ) && this.domConverter.isDomSelectionCorrect( domSelection ) ) {
 			return;
 			return;
 		}
 		}
 
 
@@ -169,20 +169,26 @@ export default class SelectionObserver extends Observer {
 			return;
 			return;
 		}
 		}
 
 
-		const data = {
-			oldSelection: this.selection,
-			newSelection: newViewSelection,
-			domSelection
-		};
-
-		// Should be fired only when selection change was the only document change.
-		this.document.fire( 'selectionChange', data );
-
-		// Call` #_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
-		// This function is debounced what means that `selectionChangeDone` event will be fired only when
-		// defined int the function time will elapse since the last time the function was called.
-		// So `selectionChangeDone` will be fired when selection will stop changing.
-		this._fireSelectionChangeDoneDebounced( data );
+		if ( this.selection.isSimilar( newViewSelection ) ) {
+			// If selection was equal and we are at this point of algorithm, it means that it was incorrect.
+			// Just re-render it, no need to fire any events, etc.
+			this.document.render();
+		} else {
+			const data = {
+				oldSelection: this.selection,
+				newSelection: newViewSelection,
+				domSelection
+			};
+
+			// Prepare data for new selection and fire appropriate events.
+			this.document.fire( 'selectionChange', data );
+
+			// Call` #_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
+			// This function is debounced what means that `selectionChangeDone` event will be fired only when
+			// defined int the function time will elapse since the last time the function was called.
+			// So `selectionChangeDone` will be fired when selection will stop changing.
+			this._fireSelectionChangeDoneDebounced( data );
+		}
 	}
 	}
 
 
 	/**
 	/**

+ 8 - 3
packages/ckeditor5-engine/src/view/range.js

@@ -130,8 +130,13 @@ export default class Range {
 	 * @returns {module:engine/view/range~Range} Shrink range.
 	 * @returns {module:engine/view/range~Range} Shrink range.
 	 */
 	 */
 	getTrimmed() {
 	getTrimmed() {
-		let start = this.start.getLastMatchingPosition( enlargeTrimSkip, { boundaries: new Range( this.start, this.end ) } );
-		let end = this.end.getLastMatchingPosition( enlargeTrimSkip, { boundaries: new Range( start, this.end ), direction: 'backward' } );
+		let start = this.start.getLastMatchingPosition( enlargeTrimSkip );
+
+		if ( start.isAfter( this.end ) || start.isEqual( this.end ) ) {
+			return new Range( start, start );
+		}
+
+		let end = this.end.getLastMatchingPosition( enlargeTrimSkip, { direction: 'backward' } );
 		const nodeAfterStart = start.nodeAfter;
 		const nodeAfterStart = start.nodeAfter;
 		const nodeBeforeEnd = end.nodeBefore;
 		const nodeBeforeEnd = end.nodeBefore;
 
 
@@ -437,7 +442,7 @@ export default class Range {
 	}
 	}
 }
 }
 
 
-// Function used by getEnlagred and getShrinked methods.
+// Function used by getEnlagred and getTrimmed methods.
 function enlargeTrimSkip( value ) {
 function enlargeTrimSkip( value ) {
 	if ( value.item.is( 'attributeElement' ) || value.item.is( 'uiElement' ) ) {
 	if ( value.item.is( 'attributeElement' ) || value.item.is( 'uiElement' ) ) {
 		return true;
 		return true;

+ 38 - 46
packages/ckeditor5-engine/src/view/renderer.js

@@ -9,7 +9,6 @@
 
 
 import ViewText from './text';
 import ViewText from './text';
 import ViewPosition from './position';
 import ViewPosition from './position';
-import Selection from './selection';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller, isBlockFiller } from './filler';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller, isBlockFiller } from './filler';
 
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
@@ -602,13 +601,45 @@ export default class Renderer {
 	 */
 	 */
 	_updateDomSelection( domRoot ) {
 	_updateDomSelection( domRoot ) {
 		const domSelection = domRoot.ownerDocument.defaultView.getSelection();
 		const domSelection = domRoot.ownerDocument.defaultView.getSelection();
+
+		// Let's check whether DOM selection needs updating at all.
+		if ( !this._domSelectionNeedsUpdate( domSelection ) ) {
+			return;
+		}
+
+		// Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
+		// set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
+		// and focus of view selection.
+		// Since we are not supporting multi-range selection, we also do not need to check if proper editable is
+		// selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
+		const anchor = this.domConverter.viewPositionToDom( this.selection.anchor );
+		const focus = this.domConverter.viewPositionToDom( this.selection.focus );
+
+		domSelection.collapse( anchor.parent, anchor.offset );
+		domSelection.extend( focus.parent, focus.offset );
+	}
+
+	/**
+	 * Checks whether given DOM selection needs to be updated.
+	 *
+	 * @private
+	 * @param {Selection} domSelection DOM selection to check.
+	 * @returns {Boolean}
+	 */
+	_domSelectionNeedsUpdate( domSelection ) {
+		if ( !this.domConverter.isDomSelectionCorrect( domSelection ) ) {
+			// Current DOM selection is in incorrect position. We need to update it.
+			return true;
+		}
+
 		const oldViewSelection = domSelection && this.domConverter.domSelectionToView( domSelection );
 		const oldViewSelection = domSelection && this.domConverter.domSelectionToView( domSelection );
 
 
 		if ( oldViewSelection && this.selection.isEqual( oldViewSelection ) ) {
 		if ( oldViewSelection && this.selection.isEqual( oldViewSelection ) ) {
-			return;
+			return false;
 		}
 		}
 
 
-		if ( oldViewSelection && areSimilarSelections( oldViewSelection, this.selection ) ) {
+		// If selection is not collapsed, it does not need to be updated if it is similar.
+		if ( !this.selection.isCollapsed && this.selection.isSimilar( oldViewSelection ) ) {
 			const data = {
 			const data = {
 				oldSelection: oldViewSelection,
 				oldSelection: oldViewSelection,
 				currentSelection: this.selection
 				currentSelection: this.selection
@@ -619,19 +650,12 @@ export default class Renderer {
 				data
 				data
 			);
 			);
 
 
-			return;
+			// Selection did not changed and is correct, do not update.
+			return false;
 		}
 		}
 
 
-		// Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
-		// set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
-		// and focus of view selection.
-		// Since we are not supporting multi-range selection, we also do not need to check if proper editable is
-		// selected. If there is any editable selected, it is okay (editable is taken from selection anchor).
-		const anchor = this.domConverter.viewPositionToDom( this.selection.anchor );
-		const focus = this.domConverter.viewPositionToDom( this.selection.focus );
-
-		domSelection.collapse( anchor.parent, anchor.offset );
-		domSelection.extend( focus.parent, focus.offset );
+		// Selections are not similar.
+		return true;
 	}
 	}
 
 
 	/**
 	/**
@@ -684,35 +708,3 @@ export default class Renderer {
 }
 }
 
 
 mix( Renderer, ObservableMixin );
 mix( Renderer, ObservableMixin );
-
-// Checks if two given selections are similar. Selections are considered similar if they are non-collapsed
-// and their trimmed (see {@link #_trimSelection}) representations are equal.
-//
-// @private
-// @param {module:engine/view/selection~Selection} selection1
-// @param {module:engine/view/selection~Selection} selection2
-// @returns {Boolean}
-function areSimilarSelections( selection1, selection2 ) {
-	return !selection1.isCollapsed && trimSelection( selection1 ).isEqual( trimSelection( selection2 ) );
-}
-
-// Creates a copy of a given selection with all of its ranges
-// trimmed (see {@link module:engine/view/range~Range#getTrimmed getTrimmed}).
-//
-// @private
-// @param {module:engine/view/selection~Selection} selection
-// @returns {module:engine/view/selection~Selection} Selection copy with all ranges trimmed.
-function trimSelection( selection ) {
-	const newSelection = Selection.createFromSelection( selection );
-	const ranges = newSelection.getRanges();
-
-	const trimmedRanges = [];
-
-	for ( const range of ranges ) {
-		trimmedRanges.push( range.getTrimmed() );
-	}
-
-	newSelection.setRanges( trimmedRanges, newSelection.isBackward );
-
-	return newSelection;
-}

+ 55 - 0
packages/ckeditor5-engine/src/view/selection.js

@@ -13,6 +13,7 @@ import Position from './position';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import Element from './element';
 import Element from './element';
+import count from '@ckeditor/ckeditor5-utils/src/count';
 
 
 /**
 /**
  * Class representing selection in tree view.
  * Class representing selection in tree view.
@@ -207,6 +208,7 @@ export default class Selection {
 	 *
 	 *
 	 * @fires change
 	 * @fires change
 	 * @param {module:engine/view/range~Range} range
 	 * @param {module:engine/view/range~Range} range
+	 * @param {Boolean} isBackward
 	 */
 	 */
 	addRange( range, isBackward ) {
 	addRange( range, isBackward ) {
 		if ( !( range instanceof Range ) ) {
 		if ( !( range instanceof Range ) ) {
@@ -338,6 +340,59 @@ export default class Selection {
 		return true;
 		return true;
 	}
 	}
 
 
+	/**
+	 * Checks whether this selection is similar to given selection. Selections are similar if they have same directions, same
+	 * number of ranges, and all {@link module:engine/view/range~Range#getTrimmed trimmed} ranges from one selection are
+	 * "touching" any trimmed range from other selection.
+	 *
+	 * Ranges touch if their start positions and end positions {@link module:engine/view/position~Position#isTouching are touching}.
+	 *
+	 * @param {module:engine/view/selection~Selection} otherSelection Selection to compare with.
+	 * @returns {Boolean} `true` if selections are similar, `false` otherwise.
+	 */
+	isSimilar( otherSelection ) {
+		if ( this.isBackward != otherSelection.isBackward ) {
+			return false;
+		}
+
+		const numOfRangesA = count( this.getRanges() );
+		const numOfRangesB = count( otherSelection.getRanges() );
+
+		// If selections have different number of ranges, they cannot be similar.
+		if ( numOfRangesA != numOfRangesB ) {
+			return false;
+		}
+
+		// If both selections have no ranges, they are similar.
+		if ( numOfRangesA == 0 ) {
+			return true;
+		}
+
+		// Check if each range in one selection has a similar range in other selection.
+		for ( let rangeA of this.getRanges() ) {
+			rangeA = rangeA.getTrimmed();
+
+			let found = false;
+
+			for ( let rangeB of otherSelection.getRanges() ) {
+				rangeB = rangeB.getTrimmed();
+
+				if ( rangeA.start.isEqual( rangeB.start ) && rangeA.end.isEqual( rangeB.end ) ) {
+					found = true;
+					break;
+				}
+			}
+
+			// For `rangeA`, neither range in `otherSelection` was similar. So selections are not similar.
+			if ( !found ) {
+				return false;
+			}
+		}
+
+		// There were no ranges that weren't matched. Selections are similar.
+		return true;
+	}
+
 	/**
 	/**
 	 * Removes all ranges that were added to the selection.
 	 * Removes all ranges that were added to the selection.
 	 *
 	 *

+ 69 - 0
packages/ckeditor5-engine/src/view/uielement.js

@@ -10,6 +10,7 @@
 import Element from './element';
 import Element from './element';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Node from './node';
 import Node from './node';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 
 /**
 /**
  * UIElement class. It is used to represent UI not a content of the document.
  * UIElement class. It is used to represent UI not a content of the document.
@@ -82,9 +83,77 @@ export default class UIElement extends Element {
 	}
 	}
 }
 }
 
 
+/**
+ * This function injects UI element handling to the given {@link module:engine/view/document~Document document}.
+ *
+ * A callback is added to {@link module:engine/view/document~Document#event:keydown document keydown event}.
+ * The callback handles the situation when right arrow key is pressed and selection is collapsed before a UI element.
+ * Without this handler, it would be impossible to "jump over" UI element using right arrow key.
+ *
+ * @param {module:engine/view/document~Document} document Document to which the quirks handling will be injected.
+ */
+export function injectUiElementHandling( document ) {
+	document.on( 'keydown', ( evt, data ) => jumpOverUiElement( evt, data, document.domConverter ) );
+}
+
 // Returns `null` because block filler is not needed for UIElements.
 // Returns `null` because block filler is not needed for UIElements.
 //
 //
 // @returns {null}
 // @returns {null}
 function getFillerOffset() {
 function getFillerOffset() {
 	return null;
 	return null;
 }
 }
+
+// Selection cannot be placed in a `UIElement`. Whenever it is placed there, it is moved before it. This
+// causes a situation when it is impossible to jump over `UIElement` using right arrow key, because the selection
+// ends up in ui element (in DOM) and is moved back to the left. This handler fixes this situation.
+function jumpOverUiElement( evt, data, domConverter ) {
+	if ( data.keyCode == keyCodes.arrowright ) {
+		const domSelection = data.domTarget.ownerDocument.defaultView.getSelection();
+		const domSelectionCollapsed = domSelection.rangeCount == 1 && domSelection.getRangeAt( 0 ).collapsed;
+
+		// Jump over UI element if selection is collapsed or shift key is pressed. These are the cases when selection would extend.
+		if ( domSelectionCollapsed || data.shiftKey ) {
+			const domParent = domSelection.focusNode;
+			const domOffset = domSelection.focusOffset;
+
+			const viewPosition = domConverter.domPositionToView( domParent, domOffset );
+
+			// In case if dom element is not converted to view or is not mapped or something. Happens for example in some tests.
+			if ( viewPosition === null ) {
+				return;
+			}
+
+			// Skip all following ui elements.
+			let jumpedOverAnyUiElement = false;
+
+			const nextViewPosition = viewPosition.getLastMatchingPosition( value => {
+				if ( value.item.is( 'uiElement' ) ) {
+					// Remember that there was at least one ui element.
+					jumpedOverAnyUiElement = true;
+				}
+
+				// Jump over ui elements, jump over empty attribute elements, move up from inside of attribute element.
+				if ( value.item.is( 'uiElement' ) || value.item.is( 'attributeElement' ) ) {
+					return true;
+				}
+
+				// Don't jump over text or don't get out of container element.
+				return false;
+			} );
+
+			// If anything has been skipped, fix position.
+			// This `if` could be possibly omitted but maybe it is better not to mess with DOM selection if not needed.
+			if ( jumpedOverAnyUiElement ) {
+				const newDomPosition = domConverter.viewPositionToDom( nextViewPosition );
+
+				if ( domSelectionCollapsed ) {
+					// Selection was collapsed, so collapse it at further position.
+					domSelection.collapse( newDomPosition.parent, newDomPosition.offset );
+				} else {
+					// Selection was not collapse, so extend it instead of collapsing.
+					domSelection.extend( newDomPosition.parent, newDomPosition.offset );
+				}
+			}
+		}
+	}
+}

+ 134 - 0
packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js

@@ -0,0 +1,134 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import ViewDocument from '../../../src/view/document';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
+import { setData } from '../../../src/dev-utils/view';
+
+describe( 'Document', () => {
+	let viewDocument, domRoot;
+
+	beforeEach( () => {
+		domRoot = createElement( document, 'div', {
+			contenteditable: 'true'
+		} );
+		document.body.appendChild( domRoot );
+
+		viewDocument = new ViewDocument();
+		viewDocument.createRoot( domRoot );
+
+		document.getSelection().removeAllRanges();
+
+		viewDocument.isFocused = true;
+	} );
+
+	afterEach( () => {
+		viewDocument.destroy();
+
+		domRoot.parentElement.removeChild( domRoot );
+	} );
+
+	describe( 'jump over ui element handler', () => {
+		it( 'jump over ui element when right arrow is pressed before ui element', () => {
+			setData( viewDocument, '<container:p>foo{}<ui:span></ui:span>bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( 'P' );
+			expect( domSelection.anchorOffset ).to.equal( 2 );
+			expect( domSelection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should do nothing when another key is pressed', () => {
+			setData( viewDocument, '<container:p>foo<ui:span></ui:span>{}bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.data ).to.equal( 'bar' );
+			expect( domSelection.anchorOffset ).to.equal( 0 );
+			expect( domSelection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should do nothing if range is not collapsed', () => {
+			setData( viewDocument, '<container:p>f{oo}<ui:span></ui:span>bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.data ).to.equal( 'foo' );
+			expect( domSelection.anchorOffset ).to.equal( 1 );
+			expect( domSelection.focusNode.data ).to.equal( 'foo' );
+			expect( domSelection.focusOffset ).to.equal( 3 );
+			expect( domSelection.isCollapsed ).to.be.false;
+		} );
+
+		it( 'jump over ui element if selection is not collapsed but shift key is pressed', () => {
+			setData( viewDocument, '<container:p>fo{o}<ui:span></ui:span>bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire(
+				'keydown',
+				{ keyCode: keyCodes.arrowright, shiftKey: true, domTarget: viewDocument.domRoots.get( 'main' ) }
+			);
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( '#TEXT' );
+			expect( domSelection.anchorOffset ).to.equal( 2 );
+			expect( domSelection.focusNode.nodeName.toUpperCase() ).to.equal( 'P' );
+			expect( domSelection.focusOffset ).to.equal( 2 );
+		} );
+
+		it( 'jump over ui element if selection is in attribute element', () => {
+			setData( viewDocument, '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire(
+				'keydown',
+				{ keyCode: keyCodes.arrowright, shiftKey: true, domTarget: viewDocument.domRoots.get( 'main' ) }
+			);
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( 'P' );
+			expect( domSelection.anchorOffset ).to.equal( 2 );
+			expect( domSelection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should do nothing if caret is not directly before ui element', () => {
+			setData( viewDocument, '<container:p>fo{}o<ui:span></ui:span>bar</container:p>' );
+			viewDocument.render();
+
+			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.data ).to.equal( 'foo' );
+			expect( domSelection.anchorOffset ).to.equal( 2 );
+			expect( domSelection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should do nothing if dom position cannot be converted to view position', () => {
+			const newDiv = document.createElement( 'div' );
+			const domSelection = document.getSelection();
+
+			document.body.appendChild( newDiv );
+			domSelection.collapse( newDiv, 0 );
+
+			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
+		} );
+	} );
+} );

+ 103 - 1
packages/ckeditor5-engine/tests/view/domconverter/domconverter.js

@@ -8,7 +8,9 @@
 import DomConverter from '../../../src/view/domconverter';
 import DomConverter from '../../../src/view/domconverter';
 import ViewEditable from '../../../src/view/editableelement';
 import ViewEditable from '../../../src/view/editableelement';
 import ViewDocument from '../../../src/view/document';
 import ViewDocument from '../../../src/view/document';
-import { BR_FILLER, NBSP_FILLER } from '../../../src/view/filler';
+import ViewUIElement from '../../../src/view/uielement';
+import ViewContainerElement from '../../../src/view/containerelement';
+import { BR_FILLER, NBSP_FILLER, INLINE_FILLER, INLINE_FILLER_LENGTH } from '../../../src/view/filler';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 
@@ -190,4 +192,104 @@ describe( 'DomConverter', () => {
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'isDomSelectionCorrect()', () => {
+		function domSelection( anchorParent, anchorOffset, focusParent, focusOffset ) {
+			const sel = document.getSelection();
+
+			sel.collapse( anchorParent, anchorOffset );
+			sel.extend( focusParent, focusOffset );
+
+			return sel;
+		}
+
+		let domP, domFillerTextNode, domUiSpan, domUiDeepSpan;
+
+		beforeEach( () => {
+			// <p>INLINE_FILLERfoo<span></span></p>.
+			domP = document.createElement( 'p' );
+			domFillerTextNode = document.createTextNode( INLINE_FILLER + 'foo' );
+			domUiSpan = document.createElement( 'span' );
+
+			domUiDeepSpan = document.createElement( 'span' );
+			domUiSpan.appendChild( domUiDeepSpan );
+
+			const viewUiSpan = new ViewUIElement( 'span' );
+			const viewElementSpan = new ViewContainerElement( 'span' );
+
+			domP.appendChild( domFillerTextNode );
+			domP.appendChild( domUiSpan );
+
+			converter.bindElements( domUiSpan, viewUiSpan );
+			converter.bindElements( domUiDeepSpan, viewElementSpan );
+
+			document.body.appendChild( domP );
+		} );
+
+		it( 'should return true for correct dom selection', () => {
+			// <p>INLINE_FILLER{foo}<span></span></p>.
+			const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+			expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.true;
+
+			// <p>INLINE_FILLERfoo[]<span></span></p>.
+			const sel2 = domSelection( domP, 1, domP, 1 );
+			expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.true;
+
+			// <p>INLINE_FILLERfoo<span></span>[]</p>.
+			const sel3 = domSelection( domP, 2, domP, 2 );
+			expect( converter.isDomSelectionCorrect( sel3 ) ).to.be.true;
+		} );
+
+		describe( 'should return false', () => {
+			it( 'if anchor or focus is before filler node', () => {
+				// Tests forward and backward selection.
+				// <p>[INLINE_FILLERfoo]<span-ui><span-container></span></span></p>.
+				const sel1 = domSelection( domP, 0, domP, 1 );
+				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
+
+				const sel2 = domSelection( domP, 1, domP, 0 );
+				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
+			} );
+
+			it( 'if anchor or focus is before filler sequence', () => {
+				// Tests forward and backward selection.
+				// <p>{INLINE_FILLERfoo}<span-ui><span-container></span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, 0, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
+
+				const sel2 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domFillerTextNode, 0 );
+				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
+			} );
+
+			it( 'if anchor or focus is in the middle of filler sequence', () => {
+				// Tests forward and backward selection.
+				// <p>I{NLINE_FILLERfoo}<span-ui><span-container></span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, 1, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
+
+				const sel2 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domFillerTextNode, 1 );
+				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
+			} );
+
+			it( 'if anchor or focus is directly inside dom element that represents view ui element', () => {
+				// Tests forward and backward selection.
+				// <p>INLINE_FILLER{foo<span-ui>]<span-container></span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domUiSpan, 0 );
+				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
+
+				const sel2 = domSelection( domUiSpan, 0, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
+			} );
+
+			it( 'if anchor or focus is inside deep ui element structure (not directly in ui element)', () => {
+				// Tests forward and backward selection.
+				// <p>INLINE_FILLER{foo<span-ui><span-container>]</span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domUiDeepSpan, 0 );
+				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
+
+				const sel2 = domSelection( domUiDeepSpan, 0, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
+			} );
+		} );
+	} );
 } );
 } );

+ 8 - 3
packages/ckeditor5-engine/tests/view/manual/uielement.html

@@ -14,11 +14,16 @@
 		font-family: sans-serif;
 		font-family: sans-serif;
 		background-color: #7a7a7a;
 		background-color: #7a7a7a;
 		color: white;
 		color: white;
-		padding: 1px 5px;
-		display: inline-block;
+		padding: 2px 5px;
+		display: inline;
 	}
 	}
 </style>
 </style>
+
 <div id="container">
 <div id="container">
-	<div id="editor"><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin. Curabitur eleifend blandit ultrices. Aliquam euismod ut lectus blandit faucibus. Quisque ut sapien euismod, interdum ex ac, tristique neque. In a volutpat risus. Vivamus blandit est et sodales imperdiet. Duis et metus nisi. Proin viverra pellentesque velit vel egestas. Etiam sed ornare orci. Pellentesque id quam finibus, semper ante aliquam, rhoncus tortor. Vestibulum eget porta velit. Aenean id nisl scelerisque, pulvinar ante nec, eleifend sapien.</p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin. Curabitur eleifend blandit ultrices. Aliquam euismod ut lectus blandit faucibus. Quisque ut sapien euismod, interdum ex ac, tristique neque. In a volutpat risus. Vivamus blandit est et sodales imperdiet. Duis et metus nisi. Proin viverra pellentesque velit vel egestas. Etiam sed ornare orci. Pellentesque id quam finibus, semper ante aliquam, rhoncus tortor. Vestibulum eget porta velit. Aenean id nisl scelerisque, pulvinar ante nec, eleifend sapien.</p></div>
+	<div id="editor">
+		<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin.</p>
+		<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin. <strong>Curabitur eleifend blandit ultrices.</strong></p>
+		<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin.</p>
+	</div>
 </div>
 </div>
 
 

+ 16 - 7
packages/ckeditor5-engine/tests/view/manual/uielement.js

@@ -3,21 +3,22 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
-/* global document */
+/* globals console, window, document */
 
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import UIElement from '../../../src/view/uielement';
 import UIElement from '../../../src/view/uielement';
 
 
 class MyUIElement extends UIElement {
 class MyUIElement extends UIElement {
 	render( domDocument ) {
 	render( domDocument ) {
 		const root = super.render( domDocument );
 		const root = super.render( domDocument );
 
 
-		root.setAttribute( 'contenteditable', 'false' );
 		root.classList.add( 'ui-element' );
 		root.classList.add( 'ui-element' );
 		root.innerHTML = 'END OF PARAGRAPH';
 		root.innerHTML = 'END OF PARAGRAPH';
 
 
@@ -38,7 +39,15 @@ class UIElementTestPlugin extends Plugin {
 	}
 	}
 }
 }
 
 
-ClassicEditor.create( document.querySelector( '#editor' ), {
-	plugins: [ Enter, Typing, Paragraph, Undo, UIElementTestPlugin ],
-	toolbar: [ 'undo', 'redo' ]
-} );
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Enter, Typing, Paragraph, Undo, Bold, Italic, UIElementTestPlugin ],
+		toolbar: [ 'undo', 'redo', 'bold', 'italic' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+

+ 2 - 0
packages/ckeditor5-engine/tests/view/manual/uielement.md

@@ -3,3 +3,5 @@
 1. Each paragraph should have UIElement at it's bottom showing "END OF PARAGRAPH".
 1. Each paragraph should have UIElement at it's bottom showing "END OF PARAGRAPH".
 1. UIElement should not block typing or prevent regular editor usage.
 1. UIElement should not block typing or prevent regular editor usage.
 1. When paragraph is split or new paragraph is created - new UIElement should be created too.
 1. When paragraph is split or new paragraph is created - new UIElement should be created too.
+1. You should not be able to place selection inside ui element or type in it.
+1. Arrow keys should work correctly around ui element.

+ 15 - 0
packages/ckeditor5-engine/tests/view/node.js

@@ -279,6 +279,21 @@ describe( 'Node', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'toJSON()', () => {
+		it( 'should prevent circular reference when stringifying a node', () => {
+			const char = new Text( 'a' );
+			const parent = new Element( 'p', null );
+			parent.appendChildren( char );
+
+			const json = JSON.stringify( char );
+			const parsed = JSON.parse( json );
+
+			expect( parsed ).to.deep.equal( {
+				_data: 'a'
+			} );
+		} );
+	} );
+
 	describe( 'change event', () => {
 	describe( 'change event', () => {
 		let root, text, img, rootChangeSpy;
 		let root, text, img, rootChangeSpy;
 
 

+ 34 - 0
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -91,6 +91,40 @@ describe( 'MutationObserver', () => {
 		expect( lastMutations[ 0 ].oldChildren[ 0 ].data ).to.equal( 'foo' );
 		expect( lastMutations[ 0 ].oldChildren[ 0 ].data ).to.equal( 'foo' );
 	} );
 	} );
 
 
+	it( 'should handle unbold', () => {
+		viewRoot.removeChildren( 0, viewRoot.childCount );
+		viewRoot.appendChildren( parse( '<container:p><attribute:b>foo</attribute:b></container:p>' ) );
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 0 ];
+		const domB = domP.childNodes[ 0 ];
+		domP.removeChild( domB );
+		domP.appendChild( document.createTextNode( 'foo' ) );
+
+		mutationObserver.flush();
+
+		// "expectDomEditorNotToChange()".
+		expect( domEditor.childNodes.length ).to.equal( 1 );
+		expect( domEditor.childNodes[ 0 ].tagName ).to.equal( 'P' );
+
+		expect( domEditor.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+		expect( domEditor.childNodes[ 0 ].childNodes[ 0 ].tagName ).to.equal( 'B' );
+
+		expect( domEditor.childNodes[ 0 ].childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+		expect( domEditor.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+
+		// Check mutations.
+		expect( lastMutations.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].type ).to.equal( 'children' );
+		expect( lastMutations[ 0 ].node ).to.equal( viewRoot.getChild( 0 ) );
+
+		expect( lastMutations[ 0 ].newChildren.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].newChildren[ 0 ].data ).to.equal( 'foo' );
+
+		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].oldChildren[ 0 ].name ).to.equal( 'b' );
+	} );
+
 	it( 'should deduplicate text changes', () => {
 	it( 'should deduplicate text changes', () => {
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foox';
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foox';
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'fooxy';
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'fooxy';

+ 43 - 4
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -34,7 +34,7 @@ describe( 'SelectionObserver', () => {
 		viewRoot = viewDocument.getRoot();
 		viewRoot = viewDocument.getRoot();
 
 
 		viewRoot.appendChildren( parse(
 		viewRoot.appendChildren( parse(
-			'<container:p>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</container:p>' +
+			'<container:p>xxx<ui:span></ui:span></container:p>' +
 			'<container:p>yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</container:p>' ) );
 			'<container:p>yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy</container:p>' ) );
 
 
 		viewDocument.render();
 		viewDocument.render();
@@ -67,7 +67,7 @@ describe( 'SelectionObserver', () => {
 			expect( data.newSelection.rangeCount ).to.equal( 1 );
 			expect( data.newSelection.rangeCount ).to.equal( 1 );
 
 
 			const newViewRange = data.newSelection.getFirstRange();
 			const newViewRange = data.newSelection.getFirstRange();
-			const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
+			const viewFoo = viewDocument.getRoot().getChild( 1 ).getChild( 0 );
 
 
 			expect( newViewRange.start.parent ).to.equal( viewFoo );
 			expect( newViewRange.start.parent ).to.equal( viewFoo );
 			expect( newViewRange.start.offset ).to.equal( 2 );
 			expect( newViewRange.start.offset ).to.equal( 2 );
@@ -266,7 +266,7 @@ describe( 'SelectionObserver', () => {
 				expect( data.newSelection.rangeCount ).to.equal( 1 );
 				expect( data.newSelection.rangeCount ).to.equal( 1 );
 
 
 				const newViewRange = data.newSelection.getFirstRange();
 				const newViewRange = data.newSelection.getFirstRange();
-				const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
+				const viewFoo = viewDocument.getRoot().getChild( 1 ).getChild( 0 );
 
 
 				expect( newViewRange.start.parent ).to.equal( viewFoo );
 				expect( newViewRange.start.parent ).to.equal( viewFoo );
 				expect( newViewRange.start.offset ).to.equal( 3 );
 				expect( newViewRange.start.offset ).to.equal( 3 );
@@ -303,9 +303,48 @@ describe( 'SelectionObserver', () => {
 		}, 100 );
 		}, 100 );
 	} );
 	} );
 
 
+	it( 'should re-render view if selections are similar if DOM selection is in incorrect place', done => {
+		const sel = domDocument.getSelection();
+
+		// Add rendering on selectionChange event to check this feature.
+		viewDocument.on( 'selectionChange', () => {
+			// Manually set selection because no handlers are set for selectionChange event in this test.
+			// Normally this is handled by view -> model -> view selection converters chain.
+			const viewSel = viewDocument.selection;
+
+			const viewAnchor = viewDocument.domConverter.domPositionToView( sel.anchorNode, sel.anchorOffset );
+			const viewFocus = viewDocument.domConverter.domPositionToView( sel.focusNode, sel.focusOffset );
+
+			viewSel.collapse( viewAnchor );
+			viewSel.setFocus( viewFocus );
+
+			viewDocument.render();
+		} );
+
+		viewDocument.once( 'selectionChange', () => {
+			// 2. Selection change has been handled.
+
+			selectionObserver.listenTo( domDocument, 'selectionchange', () => {
+				// 4. Check if view was re-rendered.
+				expect( viewDocument.render.called ).to.be.true;
+
+				done();
+			}, { priority: 'lowest' } );
+
+			// 3. Now, collapse selection in similar position, but in UI element.
+			// Current and new selection position are similar in view (but not equal!).
+			// Also add a spy to `viewDocument#render` to see if view will be re-rendered.
+			sel.collapse( domMain.childNodes[ 0 ].childNodes[ 1 ], 0 );
+			sinon.spy( viewDocument, 'render' );
+		}, { priority: 'lowest' } );
+
+		// 1. Collapse in a text node, before ui element, and wait for async selectionchange to fire selection change handling.
+		sel.collapse( domMain.childNodes[ 0 ].childNodes[ 0 ], 3 );
+	} );
+
 	function changeDomSelection() {
 	function changeDomSelection() {
 		const domSelection = domDocument.getSelection();
 		const domSelection = domDocument.getSelection();
-		const domFoo = domMain.childNodes[ 0 ].childNodes[ 0 ];
+		const domFoo = domMain.childNodes[ 1 ].childNodes[ 0 ];
 		const offset = domSelection.anchorOffset;
 		const offset = domSelection.anchorOffset;
 
 
 		domSelection.removeAllRanges();
 		domSelection.removeAllRanges();

+ 7 - 1
packages/ckeditor5-engine/tests/view/range.js

@@ -183,7 +183,7 @@ describe( 'Range', () => {
 		// As above.
 		// As above.
 		it( 'case 9', () => {
 		it( 'case 9', () => {
 			expect( trim( '<p><b></b>[<b></b>]<b></b></p>' ) )
 			expect( trim( '<p><b></b>[<b></b>]<b></b></p>' ) )
-				.to.equal( '<p><b></b><b></b>[]<b></b></p>' );
+				.to.equal( '<p><b></b><b></b><b></b>[]</p>' );
 		} );
 		} );
 
 
 		// As above.
 		// As above.
@@ -192,6 +192,12 @@ describe( 'Range', () => {
 				.to.equal( '<p><b></b><b></b>[]</p>' );
 				.to.equal( '<p><b></b><b></b>[]</p>' );
 		} );
 		} );
 
 
+		// As above.
+		it( 'case 11', () => {
+			expect( trim( '<p><b></b><b>[]</b><b></b></p>' ) )
+				.to.equal( '<p><b></b><b></b><b></b>[]</p>' );
+		} );
+
 		function trim( data ) {
 		function trim( data ) {
 			data = data
 			data = data
 				.replace( /<p>/g, '<container:p>' )
 				.replace( /<p>/g, '<container:p>' )

+ 29 - 0
packages/ckeditor5-engine/tests/view/renderer.js

@@ -1604,6 +1604,35 @@ describe( 'Renderer', () => {
 				expect( logWarnStub.notCalled ).to.true;
 				expect( logWarnStub.notCalled ).to.true;
 			} );
 			} );
 
 
+			it( 'should always render selection (even if it is same in view) if current dom selection is in incorrect place', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse( '<container:p>foo[]<ui:span></ui:span></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// In DOM, set position to: <p>foo<span>[]</span></p>. This is incorrect DOM selection (it is in view ui element).
+				// Do not change view selection.
+				// When renderer will check if the DOM selection changed, it will convert DOM selection to a view selection.
+				// Selections (current view selection and view-from-dom selection) will be equal but we will still expect re-render
+				// because DOM selection is in incorrect place.
+				const domP = domRoot.childNodes[ 0 ];
+				const domSpan = domP.childNodes[ 1 ];
+				domSelection.collapse( domSpan, 0 );
+
+				renderer.render();
+
+				// Expect that after calling `renderer.render()` the DOM selection was re-rendered (and set at correct position).
+				expect( domSelection.anchorNode ).to.equal( domP );
+				expect( domSelection.anchorOffset ).to.equal( 1 );
+				expect( domSelection.focusNode ).to.equal( domP );
+				expect( domSelection.focusOffset ).to.equal( 1 );
+			} );
+
 			it( 'should not render non-collapsed selection it is similar (element start)', () => {
 			it( 'should not render non-collapsed selection it is similar (element start)', () => {
 				const domSelection = document.getSelection();
 				const domSelection = document.getSelection();
 
 

+ 89 - 5
packages/ckeditor5-engine/tests/view/selection.js

@@ -17,11 +17,14 @@ describe( 'Selection', () => {
 	let selection, el, range1, range2, range3;
 	let selection, el, range1, range2, range3;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
+		const text = new Text( 'xxxxxxxxxxxxxxxxxxxx' );
+		el = new Element( 'p', null, text );
+
 		selection = new Selection();
 		selection = new Selection();
-		el = new Element( 'p' );
-		range1 = Range.createFromParentsAndOffsets( el, 5, el, 10 );
-		range2 = Range.createFromParentsAndOffsets( el, 1, el, 2 );
-		range3 = Range.createFromParentsAndOffsets( el, 12, el, 14 );
+
+		range1 = Range.createFromParentsAndOffsets( text, 5, text, 10 );
+		range2 = Range.createFromParentsAndOffsets( text, 1, text, 2 );
+		range3 = Range.createFromParentsAndOffsets( text, 12, text, 14 );
 	} );
 	} );
 
 
 	describe( 'constructor()', () => {
 	describe( 'constructor()', () => {
@@ -335,7 +338,8 @@ describe( 'Selection', () => {
 		} );
 		} );
 
 
 		it( 'should throw when range is intersecting with already added range', () => {
 		it( 'should throw when range is intersecting with already added range', () => {
-			const range2 = Range.createFromParentsAndOffsets( el, 7, el, 15 );
+			const text = el.getChild( 0 );
+			const range2 = Range.createFromParentsAndOffsets( text, 7, text, 15 );
 			selection.addRange( range1 );
 			selection.addRange( range1 );
 			expect( () => {
 			expect( () => {
 				selection.addRange( range2 );
 				selection.addRange( range2 );
@@ -516,6 +520,86 @@ describe( 'Selection', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'isSimilar', () => {
+		it( 'should return true if selections equal', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			const otherSelection = new Selection();
+			otherSelection.addRange( range1 );
+			otherSelection.addRange( range2 );
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.true;
+		} );
+
+		it( 'should return false if ranges count does not equal', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			const otherSelection = new Selection();
+			otherSelection.addRange( range1 );
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.false;
+		} );
+
+		it( 'should return false if trimmed ranges (other than the last added one) are not equal', () => {
+			selection.addRange( range1 );
+			selection.addRange( range3 );
+
+			const otherSelection = new Selection();
+			otherSelection.addRange( range2 );
+			otherSelection.addRange( range3 );
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.false;
+		} );
+
+		it( 'should return false if directions are not equal', () => {
+			selection.addRange( range1 );
+
+			const otherSelection = new Selection();
+			otherSelection.addRange( range1, true );
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.false;
+		} );
+
+		it( 'should return true if both selections are empty', () => {
+			const otherSelection = new Selection();
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.true;
+		} );
+
+		it( 'should return true if all ranges trimmed from both selections are equal', () => {
+			const view = parse(
+				'<container:p><attribute:span></attribute:span></container:p>' +
+				'<container:p><attribute:span>xx</attribute:span></container:p>'
+			);
+
+			const p1 = view.getChild( 0 );
+			const p2 = view.getChild( 1 );
+			const span1 = p1.getChild( 0 );
+			const span2 = p2.getChild( 0 );
+
+			// <p>[<span>{]</span>}</p><p>[<span>{xx}</span>]</p>
+			const rangeA1 = Range.createFromParentsAndOffsets( p1, 0, span1, 0 );
+			const rangeB1 = Range.createFromParentsAndOffsets( span1, 0, p1, 1 );
+			const rangeA2 = Range.createFromParentsAndOffsets( p2, 0, p2, 1 );
+			const rangeB2 = Range.createFromParentsAndOffsets( span2, 0, span2, 1 );
+
+			selection.addRange( rangeA1 );
+			selection.addRange( rangeA2 );
+
+			const otherSelection = new Selection();
+			otherSelection.addRange( rangeB2 );
+			otherSelection.addRange( rangeB1 );
+
+			expect( selection.isSimilar( otherSelection ) ).to.be.true;
+			expect( otherSelection.isSimilar( selection ) ).to.be.true;
+
+			expect( selection.isEqual( otherSelection ) ).to.be.false;
+			expect( otherSelection.isEqual( selection ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'removeAllRanges', () => {
 	describe( 'removeAllRanges', () => {
 		it( 'should remove all ranges and fire change event', done => {
 		it( 'should remove all ranges and fire change event', done => {
 			selection.addRange( range1 );
 			selection.addRange( range1 );