浏览代码

Selection handling prototype.

Piotr Jasiun 9 年之前
父节点
当前提交
105eefd7a6

+ 8 - 0
packages/ckeditor5-engine/src/treemodel/node.js

@@ -177,6 +177,14 @@ export default class Node {
 		return this._attrs[ Symbol.iterator ]();
 	}
 
+	positionBefore() {
+		return Position.createBefore( this );
+	}
+
+	positionAfter() {
+		return Position.createAfter( this );
+	}
+
 	/**
 	 * Custom toJSON method to solve child-parent circular dependencies.
 	 *

+ 19 - 0
packages/ckeditor5-engine/src/treeview/attributeelement.js

@@ -71,4 +71,23 @@ export default class AttributeElement extends Element {
 	isSimilar( otherElement ) {
 		return super.isSimilar( otherElement ) && this.priority == otherElement.priority;
 	}
+
+	needsFiller() {
+		// <b>foo</b> does not need filler
+		if ( !this.getChildCount() ) {
+			return false;
+		}
+
+		const element = this.parent;
+
+		// <p><b></b></p> needs filler -> <p><b><br></b></p>
+		while ( !( element instanceof ContainerElement ) ) {
+			if ( this.getChildCount() > 1 ) {
+				return false;
+			}
+			element = element.parent;
+		}
+
+		return this.getChildCount() === 1;
+	}
 }

+ 4 - 0
packages/ckeditor5-engine/src/treeview/containerelement.js

@@ -51,4 +51,8 @@ export default class ContainerElement extends Element {
 	constructor( name, attrs, children ) {
 		super( name, attrs, children );
 	}
+
+	needsFiller() {
+		return this.getChildCount() === 0 ? 0 : false;
+	}
 }

+ 187 - 35
packages/ckeditor5-engine/src/treeview/domconverter.js

@@ -7,8 +7,26 @@
 
 import ViewText from './text.js';
 import ViewElement from './element.js';
+import ViewPosition from './position.js';
 import ViewDocumentFragment from './documentfragment.js';
 
+export const BR_FILLER = ( domDocument ) => {
+	const fillerBr = domDocument.createElement( 'br' );
+	fillerBr.dataset.filler = true;
+
+	return fillerBr;
+}
+
+export const NBSP_FILLER = ( domDocument ) => domDocument.createTextNode( '&nbsp;' );
+
+export const INLINE_FILLER_SIZE = 7;
+
+export let INLINE_FILLER = '';
+
+for ( var i = 0; i < INLINE_FILLER_SIZE; i++ ) {
+	this.inlineFiller += '\u200b';
+}
+
 /**
  * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
  * {@link engine.treeView.DomConverter#bindElements binding} these nodes.
@@ -25,7 +43,7 @@ export default class DomConverter {
 	/**
 	 * Creates DOM converter.
 	 */
-	constructor() {
+	constructor( options = { blockFiller: BR_FILLER } ) {
 		// Using WeakMap prevent memory leaks: when the converter will be destroyed all referenced between View and DOM
 		// will be removed. Also because it is a *Weak*Map when both view and DOM elements will be removed referenced
 		// will be also removed, isn't it brilliant?
@@ -49,6 +67,10 @@ export default class DomConverter {
 		 * @member {WeakMap} engine.treeView.DomConverter#_viewToDomMapping
 		 */
 		this._viewToDomMapping = new WeakMap();
+
+		this.blockFillerCreator = options.blockFiller;
+
+		this._templateBlockFiller = options.blockFiller();
 	}
 
 	/**
@@ -77,28 +99,6 @@ export default class DomConverter {
 		this._viewToDomMapping.set( viewFragment, domFragment );
 	}
 
-	/**
-	 * Compares DOM and View nodes. Elements are same when they are bound. Text nodes are same when they have the same
-	 * text data. Nodes need to have corresponding types. In all other cases nodes are different.
-	 *
-	 * @param {Node} domNode DOM node to compare.
-	 * @param {engine.treeView.Node} viewNode View node to compare.
-	 * @returns {Boolean} True if nodes are same.
-	 */
-	compareNodes( domNode, viewNode ) {
-		// Elements.
-		if ( domNode instanceof HTMLElement && viewNode instanceof ViewElement ) {
-			return domNode === this.getCorrespondingDomElement( viewNode );
-		}
-		// Texts.
-		else if ( domNode instanceof Text && viewNode instanceof ViewText ) {
-			return domNode.data === viewNode.data;
-		}
-
-		// Not matching types.
-		return false;
-	}
-
 	/**
 	 * Converts view to DOM. For all text nodes, not bound elements and document fragments new items will
 	 * be created. For bound elements and document fragments function will return corresponding items.
@@ -110,7 +110,7 @@ export default class DomConverter {
 	 * @param {Boolean} [options.withChildren=true] If true node's and document fragment's children  will be converted too.
 	 * @returns {Node|DocumentFragment} Converted node or DocumentFragment.
 	 */
-	viewToDom( viewNode, domDocument, options ) {
+	viewToDom( viewNode, domDocument, options = { bind: false, withChildren: true } ) {
 		if ( !options ) {
 			options = {};
 		}
@@ -145,9 +145,9 @@ export default class DomConverter {
 				}
 			}
 
-			if ( options.withChildren || options.withChildren === undefined ) {
-				for ( let childView of viewNode.getChildren() ) {
-					domElement.appendChild( this.viewToDom( childView, domDocument, options ) );
+			if ( options.withChildren ) {
+				for ( child of this.viewChildrenToDom( viewNode, domDocument, options ) ) {
+					domElement.appendChild( child );
 				}
 			}
 
@@ -155,6 +155,21 @@ export default class DomConverter {
 		}
 	}
 
+	*viewChildrenToDom( viewNode, domDocument, options = { bind: false, withChildren: true } ) {
+		let fillerPositionOffset = viewNode.needsFiller();
+		let offset = 0;
+
+		for ( let childView of viewNode.getChildren() ) {
+			if ( fillerPositionOffset === offset ) {
+				yield this.blockFillerCreator( domDocument );
+			}
+
+			yield this.viewToDom( childView, domDocument, options );
+
+			offset++;
+		}
+	}
+
 	/**
 	 * Converts DOM to view. For all text nodes, not bound elements and document fragments new items will
 	 * be created. For bound elements and document fragments function will return corresponding items.
@@ -165,13 +180,21 @@ export default class DomConverter {
 	 * @param {Boolean} [options.withChildren=true] It true node's and document fragment's children will be converted too.
 	 * @returns {engine.treeView.Node|engine.treeView.DocumentFragment} Converted node or document fragment.
 	 */
-	domToView( domNode, options ) {
+	domToView( domNode, options = { bind: false, withChildren: true } ) {
 		if ( !options ) {
 			options = {};
 		}
 
+		if ( this.isBlockFiller( domNode )  ) {
+			return null;
+		}
+
 		if ( domNode instanceof Text ) {
-			return new ViewText( domNode.data );
+			if ( this.isInlineFiller( domNode )  ) {
+				return null;
+			} else {
+				return new ViewText( domNode.data );
+			}
 		} else {
 			if ( this.getCorrespondingView( domNode ) ) {
 				return this.getCorrespondingView( domNode );
@@ -202,11 +225,9 @@ export default class DomConverter {
 				}
 			}
 
-			if ( options.withChildren || options.withChildren === undefined ) {
-				for ( let i = 0, len = domNode.childNodes.length; i < len; i++ ) {
-					let domChild = domNode.childNodes[ i ];
-
-					viewElement.appendChildren( this.domToView( domChild, options ) );
+			if ( options.withChildren ) {
+				for ( let child of this.domChildrenToView( domNode, options ) ) {
+					viewElement.appendChildren( child );
 				}
 			}
 
@@ -214,6 +235,16 @@ export default class DomConverter {
 		}
 	}
 
+	*domChildrenToView( domNode, options = { bind: false, withChildren: true } ) {
+		for ( let domChild of domNode.childNodes ) {
+			const viewChild = this.domToView( domChild, options );
+
+			if ( viewChild != null ) {
+				yield viewChild;
+			}
+		}
+	}
+
 	/**
 	 * Gets corresponding view item. This function use
 	 * {@link engine.treeView.DomConverter#getCorrespondingViewElement getCorrespondingViewElement}
@@ -273,6 +304,10 @@ export default class DomConverter {
 	 * corresponding node.
 	 */
 	getCorrespondingViewText( domText ) {
+		if ( this.isInlineFiller( domText ) ) {
+			return null;
+		}
+
 		const previousSibling = domText.previousSibling;
 
 		// Try to use previous sibling to find the corresponding text node.
@@ -290,7 +325,7 @@ export default class DomConverter {
 		}
 		// Try to use parent to find the corresponding text node.
 		else {
-			const viewElement = this.getCorrespondingViewElement( domText.parentElement );
+			const viewElement = this.getCorrespondingViewElement( domText.parentNode );
 
 			if ( viewElement ) {
 				return viewElement.getChild( 0 );
@@ -372,4 +407,121 @@ export default class DomConverter {
 
 		return null;
 	}
+
+	viewPositionToDom( viewPosition ) {
+		const viewParent = viewPosition.parent;
+
+		if ( viewParent instanceof ViewText ) {
+			const domParent = this.getCorrespondingDomText( viewParent );
+			let offset = viewPosition.offset;
+
+			if ( this.startsWithFiller( domText ) ) {
+				offset += INLINE_FILLER_SIZE;
+			}
+
+			return { parent: domParent, offset: offset };
+		} else {
+			let domParent, domBefore, domAfter;
+
+			if ( viewPosition.offset == 0 ) {
+				domParent = this.getCorrespondingDom( viewPosition.parent )
+				domAfter = parent.childNodes[ 0 ];
+			} else {
+				domBefore = getCorrespondingDom( viewPosition.nodeBefore() );
+				domParent = domBefore.parentNode;
+				domAfter = domBefore.nextSibling;
+			}
+
+			if ( domAfter instanceof ViewText && this.isInlineFiller( domAfter ) ) {
+				return { parent: domAfter, offset: INLINE_FILLER_SIZE };
+			}
+
+			const offset = domBefore ? indexOf( domBefore ) + 1 : 0;
+
+			return { parent: domParent, offset: offset }
+		}
+	}
+
+	domPositionToView( domParent, domOffset ) {
+		if ( this.isBlockFiller( domParent ) ) {
+			return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
+		}
+
+		if ( domParent instanceof Text ) {
+			if ( this.isInlineFiller( domParent ) ) {
+				return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
+			}
+
+			const viewParent = this.getCorrespondingViewText( domParent );
+			let offset = domOffset;
+
+			if ( this.startsWithFiller( domText ) ) {
+				offset -= INLINE_FILLER_SIZE;
+				offset = offset < 0 ? 0 : offset;
+			}
+
+			return new ViewPosition( viewParent, offset );
+		} else {
+			if ( domOffset == 0 ) {
+				const viewParent = this.getCorrespondingView( domParent );
+
+				return new ViewPosition( viewParent, 0 );
+			} else {
+				const viewBefore = this.getCorrespondingView( domParent.childNodes[ domOffset ] );
+
+				return new ViewPosition( viewBefore.parent, viewBefore.getIndex() + 1 );
+			}
+		}
+	}
+
+	viewRangeToDom( viewRange ) {
+		const domStart = this.viewPositionToDom( viewRange.start );
+		const domEnd = this.viewPositionToDom( viewRange.end );
+
+		const domRange = new Range();
+		domRange.setStart( domStart.parent, domStart.offset );
+		domRange.setEnd( domEnd.parent, domEnd.offset );
+
+		return domRange;
+	}
+
+	domRangeToView( domRange ) {
+		const viewStart = this.domPositionToView( domRange.startContainer, domRange.startOffset );
+		const viewEnd = this.domPositionToView( domRange.endContainer, domRange.endOffset );
+
+		return new Range( viewStart, viewEnd );
+	}
+
+	domSelectionToView( domSelection ) {
+		const viewSelection = new ViewSelection();
+
+		for ( let i = 0; i < domSelection.rangeCount; i++ ) {
+			const domRange = domSelection.getRangeAt( i );
+			const viewRange = this.domRangeToView( domRange );
+
+			viewSelection.addRange( viewRange );
+		}
+
+		return viewSelection;
+	}
+
+	startsWithFiller( domText ) {
+		return ( domText.data.substr( 0, INLINE_FILLER_SIZE ) === INLINE_FILLER );
+	}
+
+	isInlineFiller( domText ) {
+		return domText.data.length == INLINE_FILLER_SIZE && this.startsWithFiller( domText );
+	}
+
+	isBlockFiller( domNode ) {
+		return domNode.isEqualNode( this._templateBlockFiller );
+	}
 }
+
+function indexOf( domNode ) {
+	let index = 0;
+
+	while ( domNode = domNode.previousSibling ) {
+		index++;
+	}
+}

+ 10 - 0
packages/ckeditor5-engine/src/treeview/node.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+import Position from './position.js';
+
 import CKEditorError from '../../utils/ckeditorerror.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import mix from '../../utils/mix.js';
@@ -156,6 +158,14 @@ export default class Node {
 		}
 	}
 
+	positionBefore() {
+		return Position.createBefore( this );
+	}
+
+	positionAfter() {
+		return Position.createAfter( this );
+	}
+
 	/**
 	 * Clones this node.
 	 *

+ 17 - 2
packages/ckeditor5-engine/src/treeview/observer/mutationobserver.js

@@ -69,6 +69,10 @@ export default class MutationObserver extends Observer {
 		this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
 	}
 
+	flush() {
+		this._onMutations( this._mutationObserver.takeRecords() );
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -108,10 +112,19 @@ export default class MutationObserver extends Observer {
 	 * @param {Array.<Object>} domMutations Array of native mutations.
 	 */
 	_onMutations( domMutations ) {
+		// As a result of this.flush() we can have an empty collection.
+		if ( domMutations.length == 0 ) {
+			return;
+		}
+
 		// Use map and set for deduplication.
 		const mutatedTexts = new Map();
 		const mutatedElements = new Set();
 
+		// Assume that all elements are in the same document.
+		const domSelection = domMutations[ 0 ].target.ownerDocument.defaultView.getSelection();
+		const viewSelection = this.domConverter.domSelectionToView( domSelection );
+
 		// Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
 		// element with changed structure anyway.
 		for ( let mutation of domMutations ) {
@@ -136,7 +149,8 @@ export default class MutationObserver extends Observer {
 						type: 'text',
 						oldText: text.data,
 						newText: mutation.target.data,
-						node: text
+						node: text,
+						selection: viewSelection
 					} );
 				}
 			}
@@ -171,7 +185,8 @@ export default class MutationObserver extends Observer {
 				type: 'children',
 				oldChildren: Array.from( viewChildren ),
 				newChildren: newViewChildren,
-				node: viewElement
+				node: viewElement,
+				selection: viewSelection
 			} );
 		}
 

+ 59 - 0
packages/ckeditor5-engine/src/treeview/observer/selectionobserver.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Observer from './observer.js';
+import MutationObserver from './mutationobserver.js';
+
+export default class SelectionObserver extends Observer {
+	constructor( treeView ) {
+		super( treeView );
+
+		this.mutationObserver = treeView.getObserver( MutationObserver );
+
+		this.treeView = treeView;
+
+		this.selection = treeView.selection;
+
+		this.domConverter = treeView.domConverter;
+
+		this.documents = new WeakSet();
+	}
+
+	observe( domElement ) {
+		const domDocument = domElement.ownerDocument;
+
+		if ( this.documents.has( domDocument ) ) {
+			return;
+		}
+
+		domDocument.addEventListener( 'selectionchange', ( domEvent ) => {
+			if ( !this.isEnabled ) {
+				return;
+			}
+
+			// Ensure the mutation event will be before selection event on all browsers.
+			this.mutationObserver.flush();
+
+			// If there were mutations then the view will be re-rendered by the mutations observer and selection
+			// will be updated, so selection will be equal and event will not be fires, as expected.
+			const domSelection = domDocument.defaultView.getSelection();
+			const newViewSelection = this.domConverter.domSelectionToView( domSelection );
+
+			if ( this.selection.isEqual( newViewSelection ) ) {
+				return;
+			}
+
+			// Should be fired only when selection change was the only document change.
+			this.treeView.fire( 'selectionchange', {
+				oldSelection: this.selection,
+				newSelection: newViewSelection
+			} );
+
+			this.treeView.render();
+		} );
+	}
+}

+ 46 - 2
packages/ckeditor5-engine/src/treeview/position.js

@@ -41,7 +41,7 @@ export default class Position {
 	 * inside text node.
 	 *
 	 * @readonly
-	 * @type {engine.treeView.Node}
+	 * @type {engine.treeView.Node|null}
 	 */
 	get nodeAfter() {
 		if ( this.parent instanceof Text ) {
@@ -56,7 +56,7 @@ export default class Position {
 	 * inside text node.
 	 *
 	 * @readonly
-	 * @type {engine.treeView.Node}
+	 * @type {engine.treeView.Node|null}
 	 */
 	get nodeBefore() {
 		if ( this.parent instanceof Text ) {
@@ -184,6 +184,50 @@ export default class Position {
 		return index < 0 ? 'BEFORE' : 'AFTER';
 	}
 
+	/**
+	 * Creates a new position after given node.
+	 *
+	 * @see {@link engine.treeView.TreeWalkerValue}
+	 *
+	 * @param {engine.treeView.Node} node Node the position should be directly after.
+	 * @returns {engine.treeView.Position}
+	 */
+	static createAfter( node ) {
+		if ( !node.parent ) {
+			/**
+			 * You can not make position after root.
+			 *
+			 * @error position-after-root
+			 * @param {engine.treeView.Node} root
+			 */
+			throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: node } );
+		}
+
+		return new Position( node.parent, node.getIndex() + 1 );
+	}
+
+	/**
+	 * Creates a new position before the given node.
+	 *
+	 * @see {@link engine.treeView.TreeWalkerValue}
+	 *
+	 * @param {engine.treeView.node} node Node the position should be directly before.
+	 * @returns {engine.treeView.Position}
+	 */
+	static createBefore( node ) {
+		if ( !node.parent ) {
+			/**
+			 * You can not make position before root.
+			 *
+			 * @error position-before-root
+			 * @param {engine.treeView.Node} root
+			 */
+			throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
+		}
+
+		return new Position( node.parent, node.getIndex() );
+	}
+
 	/**
 	 * Creates and returns a new instance of Position, which is equal to passed position.
 	 *

+ 230 - 13
packages/ckeditor5-engine/src/treeview/renderer.js

@@ -6,7 +6,16 @@
 'use strict';
 
 import diff from '../../utils/diff.js';
+import ViewText from './text.js';
+
+import { INLINE_FILLER, INLINE_FILLER_SIZE } from './domconverter.js';
+
+import diff from '../utils-diff.js';
+
 import CKEditorError from '../../utils/ckeditorerror.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+import { keyNames } from '../../utils/keyboard.js';
+
 
 /**
  * Renderer updates DOM tree, to make it a reflection of the view tree. Changed nodes need to be
@@ -23,14 +32,14 @@ export default class Renderer {
 	 *
 	 * @param {engine.treeView.DomConverter} domConverter Converter instance.
 	 */
-	constructor( domConverter ) {
+	constructor( treeView ) {
 		/**
 		 * Converter instance.
 		 *
 		 * @readonly
 		 * @member {engine.treeView.DomConverter} engine.treeView.Renderer#domConverter
 		 */
-		this.domConverter = domConverter;
+		this.domConverter = treeView.domConverter;
 
 		/**
 		 * Set of nodes which attributes changed and may need to be rendered.
@@ -55,6 +64,44 @@ export default class Renderer {
 		 * @member {Set.<engine.treeView.Node>} engine.treeView.Renderer#markedTexts
 		 */
 		this.markedTexts = new Set();
+
+		this.selection = treeView.selection;
+
+		/**
+		 * Position of the inline filler. It should always be put BEFORE the text which contains filler.
+		 *
+		 * @private
+		 * @readonly
+		 * @type {engine.treeView.Position}
+		 */
+		this._inlineFillerPosition = null;
+
+		this._domSelectionWindow = null;
+
+		this._listener = Object.create( EmitterMixin );
+
+		this._listener.listenTo( treeView, 'keydown', ( data ) => {
+			if ( data.keyCode != keyNames.arrowleft || !isInlineFillerAtSelection() ) {
+				return;
+			}
+
+			const selectionPosition = selection.getFirstPosition();
+
+			if ( selectionPosition.offset != INLINE_FILLER_SIZE ) {
+				return;
+			}
+
+			const domParent = this.domConverter( selectionPosition.parent );
+
+			// Damn iframe! I can not use global window, so element -> document -> window -> selection
+			const domSelection = domParent.ownerDocument.defaultView.getSelection();
+
+			const domRange = new Range();
+			domRange.setStart( domParent.parent, 0 );
+			domRange.collapse( true );
+			domSelection.removeAllRanges();
+			domSelection.addRange( domRange );
+		} )
 	}
 
 	/**
@@ -111,11 +158,21 @@ export default class Renderer {
 	 * {@link engine.treeView.DomConverter#getCorrespondingDomText corresponding DOM text}. The change will be handled in the
 	 * parent element.
 	 *
-	 * For nodes which changed child list it calculates a {@link diff} using {@link engine.treeView.DomConverter#compareNodes}
-	 * and add or removed nodes which changed.
+	 * For nodes which changed child list it calculates a {@link diff} and add or removed nodes which changed.
 	 */
 	render() {
 		const domConverter = this.domConverter;
+		const selection = this.selection;
+
+		if ( !isInlineFillerAtSelection() ) {
+			removeInlineFiller();
+
+			if ( needAddInlineFiller() ) {
+				this._inlineFillerPosition = selection.getFirstPosition();
+			} else {
+				this._inlineFillerPosition = null;
+			}
+		}
 
 		for ( let node of this.markedTexts ) {
 			if ( !this.markedChildren.has( node.parent ) && domConverter.getCorrespondingDom( node.parent ) ) {
@@ -131,15 +188,99 @@ export default class Renderer {
 			updateChildren( element );
 		}
 
+		updateSelection();
+
 		this.markedTexts.clear();
 		this.markedAttributes.clear();
 		this.markedChildren.clear();
 
+		function isInlineFillerAtSelection() {
+			if ( selection.rangeCount() != 1 || !selection.isCollapsed() ) {
+				return false;
+			}
+
+			const selectionPosition = selection.getFirstPosition();
+			const fillerPosition = this._inlineFillerPosition;
+
+			if ( !fillerPosition ) {
+				return false;
+			}
+
+			if ( fillerPosition.isEqual( selectionPosition )  ) {
+				return true;
+			}
+
+			if ( selectionPosition.parent instanceof ViewText ) {
+				if ( fillerPosition.isEqual( selectionPosition.parent.positionBefore() ) ) {
+					return true;
+				}
+			}
+
+			return false;
+		}
+
+		function removeInlineFiller() {
+			const domPosition = domConverter.viewPositionToDom( this._inlineFillerPosition );
+			const domText = domPosition.parent.childNodes[ domPosition.offset ];
+
+			if ( !domConverter.startsWithFiller( domText ) ) {
+				/**
+				 * No inline filler on expected position.
+				 *
+				 * @error renderer-render-no-inline-filler.
+				 */
+				throw new CKEditorError( 'renderer-render-no-inline-filler: No inline filler on expected position.' );
+			}
+
+			if ( domConverter.isInlineFiller( domText ) ) {
+				domPosition.parent.removeChild( domText );
+			} else {
+				domText.data = domText.data.substr( INLINE_FILLER_SIZE );
+			}
+		}
+
+		function needAddInlineFiller() {
+			if ( selection.rangeCount() != 1 || !selection.isCollapsed() ) {
+				return false;
+			}
+
+			const selectionPosition = selection.getFirstPosition();
+			const selectionParent = selectionPosition.parent;
+			const selectionOffset = selectionPosition.offset;
+
+			if ( !( selectionParent instanceof ViewElement ) ) {
+				return false;
+			}
+
+			// We have block filler, we do not need inline one.
+			if ( selectionOffset === selectionParent.needsFiller() ) {
+				return false;
+			}
+
+			const nodeBefore = selectionPosition.nodeBefore();
+			const nodeAfter = selectionPosition.nodeAfter();
+
+			if ( nodeBefore instanceof ViewText || nodeAfter instanceof ViewText ) {
+				return false;
+			}
+
+			return true;
+		}
+
 		function updateText( viewText ) {
 			const domText = domConverter.getCorrespondingDom( viewText );
 
-			if ( domText.data != viewText.data ) {
-				domText.data = viewText.data;
+			const actualText = domText.data;
+			let expectedText = viewText.data;
+
+			const filler = this._inlineFillerPosition;
+
+			if ( filler.parent == viewText.parent && filler.offset == viewText.offset ) {
+				expectedText = INLINE_FILLER + expectedText;
+			}
+
+			if ( actualText != expectedText ) {
+				actualText = expectedText;
 			}
 		}
 
@@ -163,26 +304,102 @@ export default class Renderer {
 
 		function updateChildren( viewElement ) {
 			const domElement = domConverter.getCorrespondingDom( viewElement );
-			const domChildren = domElement.childNodes;
-			const viewChildren = Array.from( viewElement.getChildren() );
 			const domDocument = domElement.ownerDocument;
 
-			const actions = diff( domChildren, viewChildren,
-				( domNode, viewNode ) => domConverter.compareNodes( domNode, viewNode ) );
+			const filler = this._inlineFillerPosition;
+
+			const actualDomChildren = domElement.childNodes;
+			const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument ) );
+
+			if ( filler.parent == viewElement ) {
+				expectedDomChildren.splice( filler.offset, 0, domDocument.createTextNode( INLINE_FILLER ) );
+			}
+
+			const actions = diff( actualDomChildren, expectedDomChildren, sameNodes );
 
 			let i = 0;
 
 			for ( let action of actions ) {
 				if ( action === 'INSERT' ) {
-					let domChildToInsert = domConverter.viewToDom( viewChildren[ i ], domDocument, { bind: true } );
-					domElement.insertBefore( domChildToInsert, domChildren[ i ] || null );
+					domElement.insertBefore( expectedDomChildren[ i ], actualDomChildren[ i ] || null );
 					i++;
 				} else if ( action === 'DELETE' ) {
-					domElement.removeChild( domChildren[ i ] );
+					domElement.removeChild( actualDomChildren[ i ] );
 				} else { // 'EQUAL'
 					i++;
 				}
 			}
 		}
+
+		function sameNodes( actualDomChild, expectedDomChild ) {
+			// Elements.
+			if ( actualDomChild === expectedDomChild ) {
+				return true;
+			}
+			// Texts.
+			else if ( actualDomChild instanceof Text && expectedDomChild instanceof Text ) {
+				return actualDomChild.data === expectedDomChild.data;
+			}
+			// Block fillers.
+			else if ( domConverter.isBlockFiller( actualDomChild ) && domConverter.isBlockFiller( expectedDomChild ) ) {
+				return true;
+			}
+
+			// Not matching types.
+			return false;
+		}
+
+		function updateSelection() {
+			const domSelection = this._domSelectionWindow && this._domSelectionWindow.getSelection();
+			const oldViewSelection = domSelection && domConverter.domSelectionToView( domSelection );
+
+			if ( ( !oldViewSelection && !selection.rangeCount ) || selection.isEqual( oldViewSelection ) ) {
+				return;
+			}
+
+			if ( domSelection ) {
+				domSelection.removeAllRanges();
+			}
+
+			for ( let range of selection.getRanges() ) {
+				const domRangeStart = domConverter.viewPositionToDom( range.start );
+				const domRangeEnd = domConverter.viewPositionToDom( range.end );
+
+				const domRange = new Range();
+				domRange.setStart( domRangeStart.parent, domRangeStart.offset );
+				domRange.setEnd( domRangeEnd.parent, domRangeEnd.offset );
+				domSelection.addRange( range );
+			}
+
+			if ( selection.rangeCount ) {
+				// Get window for selection: Selection -> Range -> element -> document -> window.
+				this._domSelectionWindow = domSelection.getRangeAt( 0 ).startContainer.ownerDocument.defaultView;
+			} else {
+				this._domSelectionWindow = null;
+			}
+		}
+
+		function sameSelections( actualDomSelection, expectedDomSelection ) {
+			if ( actualDomSelection.rangeCount !== expectedDomSelection.rangeCount ) {
+				return false;
+			}
+
+			for ( let i = 0; i < actualDomSelection.rangeCount; i++ ) {
+				const actualRange = actualDomSelection.getRangeAt( i );
+				const expectedRange = expectedDomSelection[ i ];
+
+				if ( actualRange.startContainer != expectedRange.startContainer ) {
+					return false;
+				} else if ( actualRange.endContainer != expectedRange.endContainer ) {
+					return false;
+				} else if ( actualRange.startOffset != expectedRange.startOffset ) {
+					return false;
+				} else if ( actualRange.endOffset != expectedRange.endOffset ) {
+					return false;
+				}
+			}
+
+			return true;
+		}
 	}
 }

+ 24 - 2
packages/ckeditor5-engine/src/treeview/selection.js

@@ -35,7 +35,7 @@ export default class Selection {
 		/**
 		 * Stores all ranges that are selected.
 		 *
-		 * @private
+		 * @protected
 		 * @member {Array.<engine.treeView.Range>} engine.treeView.Selection#_ranges
 		 */
 		this._ranges = [];
@@ -43,7 +43,7 @@ export default class Selection {
 		/**
 		 * Specifies whether the last added range was added as a backward or forward range.
 		 *
-		 * @private
+		 * @protected
 		 * @member {Boolean} engine.treeView.Selection#_lastRangeBackward
 		 */
 		this._lastRangeBackward = false;
@@ -206,6 +206,28 @@ export default class Selection {
 		return lastRange ? Position.createFromPosition( lastRange.end ) : null;
 	}
 
+	/**
+	 * Two ranges equal if their start and end positions equal.
+	 *
+	 * @param {engine.treeView.Range} otherRange Range to compare with.
+	 * @returns {Boolean} True if ranges equal.
+	 */
+	isEqual( otherSelection ) {
+		const rangeCount = this.rangeCount;
+
+		if ( rangeCount != otherSelection.rangeCount() ) {
+			return false;
+		}
+
+		for ( let i = 0; i < this.rangeCount; i++ ) {
+			if ( !this._ranges[ i ].isEqual( otherSelection._ranges[ i ] ) ) {
+				return false;
+			}
+		}
+
+		return this._lastRangeBackward === otherSelection._lastRangeBackward;
+	}
+
 	/**
 	 * Removes all ranges that were added to the selection.
 	 *

+ 11 - 17
packages/ckeditor5-engine/src/treeview/treeview.js

@@ -73,10 +73,10 @@ export default class TreeView {
 		/**
 		 * Set of registered {@link engine.treeView.Observer observers}.
 		 *
-		 * @protected
+		 * @private
 		 * @member {Set.<engine.treeView.Observer>} engine.treeView.TreeView_#observers
 		 */
-		this._observers = new Set();
+		this._observers = new Map();
 	}
 
 	/**
@@ -92,13 +92,13 @@ export default class TreeView {
 	 * Should create an instance inheriting from {@link engine.treeView.observer.Observer}.
 	 */
 	addObserver( Observer ) {
-		if ( this._hasObserver( Observer ) ) {
+		if ( this._observers.has( Observer ) ) {
 			return;
 		}
 
 		const observer = new Observer( this );
 
-		this._observers.add( observer );
+		this._observers.set( Observer, observer );
 
 		for ( let [ name, domElement ] of this.domRoots ) {
 			observer.observe( domElement, name );
@@ -107,6 +107,10 @@ export default class TreeView {
 		observer.enable();
 	}
 
+	getObserver( Observer ) {
+		return this._observers.get( Observer );
+	}
+
 	/**
 	 * Creates a root for the HTMLElement. It adds elements to {@link engine.treeView.TreeView#domRoots} and
 	 * {@link engine.treeView.TreeView#viewRoots}.
@@ -133,7 +137,7 @@ export default class TreeView {
 		this.domRoots.set( name, domRoot );
 		this.viewRoots.set( name, viewRoot );
 
-		for ( let observer of this._observers ) {
+		for ( let observer of this._observers.values() ) {
 			observer.observe( domRoot, name );
 		}
 
@@ -145,26 +149,16 @@ export default class TreeView {
 	 * before rendering and reattached after that.
 	 */
 	render() {
-		for ( let observer of this._observers ) {
+		for ( let observer of this._observers.values() ) {
 			observer.disable();
 		}
 
 		this.renderer.render();
 
-		for ( let observer of this._observers ) {
+		for ( let observer of this._observers.values() ) {
 			observer.enable();
 		}
 	}
-
-	/**
-	 * Checks whether the given observer was already added.
-	 *
-	 * @private
-	 * @param {Function} Observer The observer constructor to check.
-	 */
-	_hasObserver( Observer ) {
-		return Array.from( this._observers ).some( ( observer ) => observer.constructor === Observer );
-	}
 }
 
 mix( TreeView, EmitterMixin );