Browse Source

Merge pull request #650 from ckeditor/t/627

Fake selection.
Piotrek Koszuliński 9 years ago
parent
commit
363bff14e7

+ 3 - 1
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -11,7 +11,8 @@ import { convertSelectionChange } from '../conversion/view-selection-to-model-co
 import {
 	convertRangeSelection,
 	convertCollapsedSelection,
-	clearAttributes
+	clearAttributes,
+	clearFakeSelection
 } from '../conversion/model-selection-to-view-converters.js';
 
 import EmitterMixin from '../../utils/emittermixin.js';
@@ -105,6 +106,7 @@ export default class EditingController {
 
 		// Attach default selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
+		this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
 		this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );
 		this.modelToView.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
 	}

+ 8 - 0
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -209,3 +209,11 @@ export function clearAttributes() {
 		conversionApi.viewSelection.removeAllRanges();
 	};
 }
+
+/**
+ * Function factory, creates a converter that clears fake selection marking after the previous
+ * {@link engine.model.Selection model selection} conversion.
+ */
+export function clearFakeSelection() {
+	return ( evt, data, consumable, conversionApi ) => conversionApi.viewSelection.setFake( false );
+}

+ 4 - 1
packages/ckeditor5-engine/src/view/document.js

@@ -13,6 +13,7 @@ import MutationObserver from './observer/mutationobserver.js';
 import SelectionObserver from './observer/selectionobserver.js';
 import FocusObserver from './observer/focusobserver.js';
 import KeyObserver from './observer/keyobserver.js';
+import FakeSelectionObserver from './observer/fakeselectionobserver.js';
 import mix from '../../utils/mix.js';
 import ObservableMixin from '../../utils/observablemixin.js';
 
@@ -30,7 +31,8 @@ import ObservableMixin from '../../utils/observablemixin.js';
  * * {@link view.observer.MutationObserver},
  * * {@link view.observer.SelectionObserver},
  * * {@link view.observer.FocusObserver},
- * * {@link view.observer.KeyObserver}.
+ * * {@link view.observer.KeyObserver},
+ * * {@link view.observer.FakeSelectionObserver}.
  *
  * @memberOf engine.view
  * @mixes utils.EmitterMixin
@@ -107,6 +109,7 @@ export default class Document {
 		this.addObserver( SelectionObserver );
 		this.addObserver( FocusObserver );
 		this.addObserver( KeyObserver );
+		this.addObserver( FakeSelectionObserver );
 
 		injectQuirksHandling( this );
 

+ 47 - 1
packages/ckeditor5-engine/src/view/domconverter.js

@@ -87,6 +87,36 @@ export default class DomConverter {
 		 * @member {WeakMap} engine.view.DomConverter#_viewToDomMapping
 		 */
 		this._viewToDomMapping = new WeakMap();
+
+		/**
+		 * Holds mapping between fake selection containers and corresponding view selections.
+		 *
+		 * @private
+		 * @member {WeakMap} engine.view.DomConverter#_fakeSelectionMapping
+		 */
+		this._fakeSelectionMapping = new WeakMap();
+	}
+
+	/**
+	 * Binds given DOM element that represents fake selection to {@link engine.view.Selection view selection}.
+	 * View selection copy is stored and can be retrieved by {@link engine.view.DomConverter#fakeSelectionToView} method.
+	 *
+	 * @param {HTMLElement} domElement
+	 * @param {engine.view.Selection} viewSelection
+	 */
+	bindFakeSelection( domElement, viewSelection ) {
+		this._fakeSelectionMapping.set( domElement, ViewSelection.createFromSelection( viewSelection ) );
+	}
+
+	/**
+	 * Returns {@link engine.view.Selection view selection} instance corresponding to given DOM element that represents fake
+	 * selection. Returns `undefined` if binding to given DOM element does not exists.
+	 *
+	 * @param {HTMLElement} domElement
+	 * @returns {engine.view.Selection|undefined}
+	 */
+	fakeSelectionToView( domElement ) {
+		return this._fakeSelectionMapping.get( domElement );
 	}
 
 	/**
@@ -358,8 +388,24 @@ export default class DomConverter {
 	 * @returns {engine.view.Selection} View selection.
 	 */
 	domSelectionToView( domSelection ) {
-		const viewSelection = new ViewSelection();
+		// DOM selection might be placed in fake selection container.
+		// If container contains fake selection - return corresponding view selection.
+		if ( domSelection.rangeCount === 1 ) {
+			let container = domSelection.getRangeAt( 0 ).startContainer;
+
+			// The DOM selection might be moved to the text node inside the fake selection container.
+			if ( this.isText( container ) ) {
+				container = container.parentNode;
+			}
 
+			const viewSelection = this.fakeSelectionToView( container );
+
+			if ( viewSelection ) {
+				return viewSelection;
+			}
+		}
+
+		const viewSelection = new ViewSelection();
 		const isBackward = this.isDomSelectionBackward( domSelection );
 
 		for ( let i = 0; i < domSelection.rangeCount; i++ ) {

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/domeventobserver.js

@@ -23,8 +23,8 @@ import DomEventData from './domeventdata.js';
  *				return 'click';
  *			}
  *
- *			onDomEvent( domEvt ) {
- *				this.fire( 'click' );
+ *			onDomEvent( domEvent ) {
+ *				this.fire( 'click', domEvent );
  *			}
  *		}
  *

+ 94 - 0
packages/ckeditor5-engine/src/view/observer/fakeselectionobserver.js

@@ -0,0 +1,94 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Observer from './observer.js';
+import ViewSelection from '../selection.js';
+import { keyCodes } from '../../../utils/keyboard.js';
+
+/**
+ * Fake selection observer class. If view selection is fake it is placed in dummy DOM container. This observer listens
+ * on {@link engine.view.Document#keydown keydown} events and handles moving fake view selection to the correct place
+ * if arrow keys are pressed.
+ * Fires {@link engine.view.Document#selectionChage selectionChange event} simulating natural behaviour of
+ * {@link engine.view.observer.SelectionObserver SelectionObserver}.
+ *
+ * @memberOf engine.view.observer
+ * @extends engine.view.observer.Observer
+ */
+export default class FakeSelectionObserver extends Observer {
+	/**
+	 * Creates new FakeSelectionObserver instance.
+	 *
+	 * @param {engine.view.Document} document
+	 */
+	constructor( document ) {
+		super( document );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	observe() {
+		const document = this.document;
+
+		document.on( 'keydown', ( eventInfo, data ) => {
+			const selection = document.selection;
+
+			if ( selection.isFake && _isArrowKeyCode( data.keyCode ) && this.isEnabled ) {
+				// Prevents default key down handling - no selection change will occur.
+				data.preventDefault();
+
+				this._handleSelectionMove( data.keyCode );
+			}
+		}, { priority: 'lowest' } );
+	}
+
+	/**
+	 * Handles collapsing view selection according to given key code. If left or up key is provided - new selection will be
+	 * collapsed to left. If right or down key is pressed - new selection will be collapsed to right.
+	 *
+	 * This method fires {@link engine.view.Document#selectionChange} event imitating behaviour of
+	 * {@link engine.view.observer.SelectionObserver}.
+	 *
+	 * @private
+	 * @param {Number} keyCode
+	 * @fires engine.view.Document#selectionChage
+	 */
+	_handleSelectionMove( keyCode ) {
+		const selection = this.document.selection;
+		const newSelection = ViewSelection.createFromSelection( selection );
+		newSelection.setFake( false );
+
+		// Left or up arrow pressed - move selection to start.
+		if ( keyCode == keyCodes.arrowleft || keyCode == keyCodes.arrowup ) {
+			newSelection.collapseToStart();
+		}
+
+		// Right or down arrow pressed - move selection to end.
+		if ( keyCode == keyCodes.arrowright || keyCode == keyCodes.arrowdown ) {
+			newSelection.collapseToEnd();
+		}
+
+		// Fire dummy selection change event.
+		this.document.fire( 'selectionChange', {
+			oldSelection: selection,
+			newSelection: newSelection,
+			domSelection: null
+		} );
+	}
+}
+
+// Checks if one of the arrow keys is pressed.
+//
+// @private
+// @param {Number} keyCode
+// @returns {Boolean}
+function _isArrowKeyCode( keyCode ) {
+	return keyCode == keyCodes.arrowright ||
+		keyCode == keyCodes.arrowleft ||
+		keyCode == keyCodes.arrowup ||
+		keyCode == keyCodes.arrowdown;
+}
+

+ 0 - 1
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -124,7 +124,6 @@ export default class SelectionObserver extends Observer {
 		// If there were mutations then the view will be re-rendered by the mutation observer and selection
 		// will be updated, so selections will equal and event will not be fired, as expected.
 		const domSelection = domDocument.defaultView.getSelection();
-
 		const newViewSelection = this.domConverter.domSelectionToView( domSelection );
 
 		if ( this.selection.isEqual( newViewSelection ) ) {

+ 85 - 6
packages/ckeditor5-engine/src/view/renderer.js

@@ -15,6 +15,8 @@ import remove from '../../utils/dom/remove.js';
 import ObservableMixin from '../../utils/observablemixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
+/* global Range */
+
 /**
  * Renderer updates DOM structure and selection, to make them a reflection of the view structure and selection.
  *
@@ -101,6 +103,14 @@ export default class Renderer {
 		 * @member {Boolean} engine.view.Renderer#isFocused
 		 */
 		this.isFocused = false;
+
+		/**
+		 * DOM element containing fake selection.
+		 *
+		 * @private
+		 * @type {null|HTMLElement}
+		 */
+		this._fakeSelectionContainer = null;
 	}
 
 	/**
@@ -418,24 +428,75 @@ export default class Renderer {
 	 * @private
 	 */
 	_updateSelection() {
-		// If there is no selection - remove it from DOM elements that belongs to the editor.
+		// If there is no selection - remove DOM and fake selections.
 		if ( this.selection.rangeCount === 0 ) {
 			this._removeDomSelection();
+			this._removeFakeSelection();
 
 			return;
 		}
 
-		if ( !this.isFocused ) {
+		const domRoot = this.domConverter.getCorrespondingDomElement( this.selection.editableElement );
+
+		// Do nothing if there is no focus, or there is no DOM element corresponding to selection's editable element.
+		if ( !this.isFocused || !domRoot ) {
 			return;
 		}
 
-		const selectedEditable = this.selection.editableElement;
-		const domRoot = this.domConverter.getCorrespondingDomElement( selectedEditable );
+		// Render selection.
+		if ( this.selection.isFake ) {
+			this._updateFakeSelection( domRoot );
+		} else {
+			this._removeFakeSelection();
+			this._updateDomSelection( domRoot );
+		}
+	}
 
-		if ( !domRoot ) {
-			return;
+	/**
+	 * Updates fake selection.
+	 *
+	 * @private
+	 * @param {HTMLElement} domRoot Valid DOM root where fake selection container should be added.
+	 */
+	_updateFakeSelection( domRoot ) {
+		const domDocument = domRoot.ownerDocument;
+
+		// Create fake selection container if one does not exist.
+		if ( !this._fakeSelectionContainer ) {
+			this._fakeSelectionContainer = domDocument.createElement( 'div' );
+			this._fakeSelectionContainer.style.position = 'fixed';
+			this._fakeSelectionContainer.style.top = 0;
+			this._fakeSelectionContainer.style.left = '-9999px';
+			this._fakeSelectionContainer.appendChild( domDocument.createTextNode( '\u00A0' ) );
 		}
 
+		// Add fake container if not already added.
+		if ( !this._fakeSelectionContainer.parentElement ) {
+			domRoot.appendChild( this._fakeSelectionContainer );
+		}
+
+		// Update contents.
+		const content = this.selection.fakeSelectionLabel || '\u00A0';
+		this._fakeSelectionContainer.firstChild.data = content;
+
+		// Update selection.
+		const domSelection = domDocument.getSelection();
+		domSelection.removeAllRanges();
+		const domRange = new Range();
+		domRange.selectNodeContents( this._fakeSelectionContainer );
+		domSelection.addRange( domRange );
+
+		// Bind fake selection container with current selection.
+		this.domConverter.bindFakeSelection( this._fakeSelectionContainer, this.selection );
+	}
+
+	/**
+	 * Updates DOM selection.
+	 *
+	 * @private
+	 * @param {HTMLElement} domRoot Valid DOM root where DOM selection should be rendered.
+	 */
+	_updateDomSelection( domRoot ) {
 		const domSelection = domRoot.ownerDocument.defaultView.getSelection();
 		const oldViewSelection = domSelection && this.domConverter.domSelectionToView( domSelection );
 
@@ -455,6 +516,11 @@ export default class Renderer {
 		domSelection.extend( focus.parent, focus.offset );
 	}
 
+	/**
+	 * Removes DOM selection.
+	 *
+	 * @private
+	 */
 	_removeDomSelection() {
 		for ( let doc of this.domDocuments ) {
 			const domSelection = doc.getSelection();
@@ -471,6 +537,19 @@ export default class Renderer {
 	}
 
 	/**
+	 * Removes fake selection.
+	 *
+	 * @private
+	 */
+	_removeFakeSelection() {
+		const container = this._fakeSelectionContainer;
+
+		if ( container ) {
+			container.remove();
+		}
+	}
+
+	/**
 	 * Checks if focus needs to be updated and possibly updates it.
 	 *
 	 * @private

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

@@ -45,6 +45,62 @@ export default class Selection {
 		 * @member {Boolean} engine.view.Selection#_lastRangeBackward
 		 */
 		this._lastRangeBackward = false;
+
+		/**
+		 * Specifies whether selection instance is fake.
+		 *
+		 * @private
+		 * @member {Boolean} engine.view.Selection#_isFake
+		 */
+		this._isFake = false;
+
+		/**
+		 * Fake selection's label.
+		 *
+		 * @private
+		 * @member {String} engine.view.Selection#_fakeSelectionLabel
+		 */
+		this._fakeSelectionLabel = '';
+	}
+
+	/**
+	 * Sets this selection instance to be marked as `fake`. A fake selection does not render as browser native selection
+	 * over selected elements and is hidden to the user. This way, no native selection UI artifacts are displayed to
+	 * the user and selection over elements can be represented in other way, for example by applying proper CSS class.
+	 *
+	 * Additionally fake's selection label can be provided. It will be used to describe fake selection in DOM (and be
+	 * properly handled by screen readers).
+	 *
+	 * @fires engine.view.Selection#change
+	 * @param {Boolean} [value=true] If set to true selection will be marked as `fake`.
+	 * @param {Object} [options] Additional options.
+	 * @param {String} [options.label=''] Fake selection label.
+	 */
+	setFake( value = true, options = {} ) {
+		this._isFake = value;
+		this._fakeSelectionLabel = value ? options.label || '' : '';
+
+		this.fire( 'change' );
+	}
+
+	/**
+	 * Returns true if selection instance is marked as `fake`.
+	 *
+	 * @see {@link engine.view.Selection#setFake}
+	 * @returns {Boolean}
+	 */
+	get isFake() {
+		return this._isFake;
+	}
+
+	/**
+	 * Returns fake selection label.
+	 *
+	 * @see {@link engine.view.Selection#setFake}
+	 * @returns {String}
+	 */
+	get fakeSelectionLabel() {
+		return this._fakeSelectionLabel;
 	}
 
 	/**
@@ -239,6 +295,14 @@ export default class Selection {
 			return false;
 		}
 
+		if ( this.isFake != otherSelection.isFake ) {
+			return false;
+		}
+
+		if ( this.isFake && this.fakeSelectionLabel != otherSelection.fakeSelectionLabel ) {
+			return false;
+		}
+
 		for ( let i = 0; i < this.rangeCount; i++ ) {
 			if ( !this._ranges[ i ].isEqual( otherSelection._ranges[ i ] ) ) {
 				return false;
@@ -292,6 +356,9 @@ export default class Selection {
 	 * @param {engine.view.Selection} otherSelection
 	 */
 	setTo( otherSelection ) {
+		this._isFake = otherSelection._isFake;
+		this._fakeSelectionLabel = otherSelection._fakeSelectionLabel;
+
 		this.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
 	}
 

+ 13 - 1
packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js

@@ -21,7 +21,8 @@ import {
 	convertRangeSelection,
 	convertCollapsedSelection,
 	convertSelectionAttribute,
-	clearAttributes
+	clearAttributes,
+	clearFakeSelection
 } from '/ckeditor5/engine/conversion/model-selection-to-view-converters.js';
 
 import {
@@ -309,6 +310,17 @@ describe( 'clean-up', () => {
 			expect( viewString ).to.equal( '<div>f{}oobar</div>' );
 		} );
 	} );
+
+	describe( 'clearFakeSelection', () => {
+		it( 'should clear fake selection', () => {
+			dispatcher.on( 'selection', clearFakeSelection() );
+			viewSelection.setFake( true );
+
+			dispatcher.convertSelection( modelSelection );
+
+			expect( viewSelection.isFake ).to.be.false;
+		} );
+	} );
 } );
 
 describe( 'using element creator for attributes conversion', () => {

+ 3 - 1
packages/ckeditor5-engine/tests/view/document/document.js

@@ -12,6 +12,7 @@ import MutationObserver from '/ckeditor5/engine/view/observer/mutationobserver.j
 import SelectionObserver from '/ckeditor5/engine/view/observer/selectionobserver.js';
 import FocusObserver from '/ckeditor5/engine/view/observer/focusobserver.js';
 import KeyObserver from '/ckeditor5/engine/view/observer/keyobserver.js';
+import FakeSelectionObserver from '/ckeditor5/engine/view/observer/fakeselectionobserver.js';
 import Renderer from '/ckeditor5/engine/view/renderer.js';
 import ViewRange from '/ckeditor5/engine/view/range.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
@@ -22,7 +23,7 @@ import log from '/ckeditor5/utils/log.js';
 testUtils.createSinonSandbox();
 
 describe( 'Document', () => {
-	const DEFAULT_OBSERVERS_COUNT = 4;
+	const DEFAULT_OBSERVERS_COUNT = 5;
 	let ObserverMock, ObserverMockGlobalCount, instantiated, enabled;
 
 	beforeEach( () => {
@@ -72,6 +73,7 @@ describe( 'Document', () => {
 			expect( viewDocument.getObserver( SelectionObserver ) ).to.be.instanceof( SelectionObserver );
 			expect( viewDocument.getObserver( FocusObserver ) ).to.be.instanceof( FocusObserver );
 			expect( viewDocument.getObserver( KeyObserver ) ).to.be.instanceof( KeyObserver );
+			expect( viewDocument.getObserver( FakeSelectionObserver ) ).to.be.instanceof( FakeSelectionObserver );
 		} );
 	} );
 

+ 32 - 0
packages/ckeditor5-engine/tests/view/domconverter/binding.js

@@ -7,6 +7,8 @@
 /* bender-tags: view, domconverter, browser-only */
 
 import ViewElement from '/ckeditor5/engine/view/element.js';
+import ViewSelection from '/ckeditor5/engine/view/selection.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
 import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import { INLINE_FILLER } from '/ckeditor5/engine/view/filler.js';
@@ -334,4 +336,34 @@ describe( 'DomConverter', () => {
 			expect( converter.getCorrespondingDomText( viewText ) ).to.be.null;
 		} );
 	} );
+
+	describe( 'bindFakeSelection', () => {
+		let domEl, selection, viewElement;
+
+		beforeEach( () => {
+			viewElement = new ViewElement();
+			domEl = document.createElement( 'div' );
+			selection = new ViewSelection();
+			selection.addRange( ViewRange.createIn( viewElement ) );
+			converter.bindFakeSelection( domEl, selection );
+		} );
+
+		it( 'should bind DOM element to selection', () => {
+			const bindSelection = converter.fakeSelectionToView( domEl );
+			expect( bindSelection ).to.be.defined;
+			expect( bindSelection.isEqual( selection ) ).to.be.true;
+		} );
+
+		it( 'should keep a copy of selection', () => {
+			const selectionCopy = ViewSelection.createFromSelection( selection );
+
+			selection.addRange( ViewRange.createIn( new ViewElement() ), true );
+			const bindSelection = converter.fakeSelectionToView( domEl );
+
+			expect( bindSelection ).to.be.defined;
+			expect( bindSelection ).to.not.equal( selection );
+			expect( bindSelection.isEqual( selection ) ).to.be.false;
+			expect( bindSelection.isEqual( selectionCopy ) ).to.be.true;
+		} );
+	} );
 } );

+ 42 - 0
packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js

@@ -7,6 +7,8 @@
 /* bender-tags: view, domconverter, browser-only */
 
 import ViewElement from '/ckeditor5/engine/view/element.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
+import ViewSelection from '/ckeditor5/engine/view/selection.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
 import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, NBSP_FILLER } from '/ckeditor5/engine/view/filler.js';
@@ -653,5 +655,45 @@ describe( 'DomConverter', () => {
 
 			expect( viewSelection.rangeCount ).to.equal( 0 );
 		} );
+
+		it( 'should return fake selection', () => {
+			const domContainer = document.createElement( 'div' );
+			const domSelection = document.getSelection();
+			domContainer.innerHTML = 'fake selection container';
+			document.body.appendChild( domContainer );
+
+			const viewSelection = new ViewSelection();
+			viewSelection.addRange( ViewRange.createIn( new ViewElement() ) );
+			converter.bindFakeSelection( domContainer, viewSelection );
+
+			const domRange = new Range();
+			domRange.selectNodeContents( domContainer );
+			domSelection.removeAllRanges();
+			domSelection.addRange( domRange );
+
+			const bindViewSelection = converter.domSelectionToView( domSelection );
+
+			expect( bindViewSelection.isEqual( viewSelection ) ).to.be.true;
+		} );
+
+		it( 'should return fake selection if selection is placed inside text node', () => {
+			const domContainer = document.createElement( 'div' );
+			const domSelection = document.getSelection();
+			domContainer.innerHTML = 'fake selection container';
+			document.body.appendChild( domContainer );
+
+			const viewSelection = new ViewSelection();
+			viewSelection.addRange( ViewRange.createIn( new ViewElement() ) );
+			converter.bindFakeSelection( domContainer, viewSelection );
+
+			const domRange = new Range();
+			domRange.selectNodeContents( domContainer.firstChild );
+			domSelection.removeAllRanges();
+			domSelection.addRange( domRange );
+
+			const bindViewSelection = converter.domSelectionToView( domSelection );
+
+			expect( bindViewSelection.isEqual( viewSelection ) ).to.be.true;
+		} );
 	} );
 } );

+ 28 - 0
packages/ckeditor5-engine/tests/view/manual/fakeselection.html

@@ -0,0 +1,28 @@
+<style>
+	#editor {
+		padding: 5px;
+		border: solid 1px #000;
+	}
+
+	#editor strong {
+		cursor: pointer;
+	}
+
+	#editor strong.selected {
+		background-color: #dadada;
+	}
+
+	#editor strong.selected.focused {
+		background-color: yellow;
+	}
+</style>
+
+<p>Scroll down.</p>
+
+<br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br>
+
+<div contenteditable="true" id="editor"></div>
+
+<br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br>

+ 81 - 0
packages/ckeditor5-engine/tests/view/manual/fakeselection.js

@@ -0,0 +1,81 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, console */
+
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import DomEventObserver from '/ckeditor5/engine/view/observer/domeventobserver.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
+import { setData } from '/ckeditor5/engine/dev-utils/view.js';
+
+const viewDocument = new ViewDocument();
+const domEditable = document.getElementById( 'editor' );
+const viewRoot = viewDocument.createRoot( domEditable );
+let viewStrong;
+
+// Add mouseup oberver.
+viewDocument.addObserver( class extends DomEventObserver {
+	get domEventType() {
+		return [ 'mousedown', 'mouseup' ];
+	}
+
+	onDomEvent( domEvent ) {
+		this.fire( domEvent.type, domEvent );
+	}
+} );
+
+viewDocument.on( 'selectionChange', ( evt, data ) => {
+	viewDocument.selection.setTo( data.newSelection );
+	viewDocument.render();
+} );
+
+viewDocument.on( 'mouseup', ( evt, data ) => {
+	if ( data.target == viewStrong ) {
+		console.log( 'Making selection around the <strong>.' );
+
+		const range = ViewRange.createOn( viewStrong );
+		viewDocument.selection.setRanges( [ range ] );
+		viewDocument.selection.setFake( true, { label: 'fake selection over bar' } );
+
+		viewDocument.render();
+
+		data.preventDefault();
+	}
+} );
+
+viewDocument.selection.on( 'change', () => {
+	if ( !viewStrong ) {
+		return;
+	}
+
+	const firstPos = viewDocument.selection.getFirstPosition();
+	const lastPos = viewDocument.selection.getLastPosition();
+
+	if ( firstPos && lastPos && firstPos.nodeAfter == viewStrong && lastPos.nodeBefore == viewStrong ) {
+		viewStrong.addClass( 'selected' );
+	} else {
+		viewStrong.removeClass( 'selected' );
+	}
+} );
+
+viewDocument.on( 'focus', () => {
+	viewStrong.addClass( 'focused' );
+	viewDocument.render();
+
+	console.log( 'The document was focused.' );
+} );
+
+viewDocument.on( 'blur', () => {
+	viewStrong.removeClass( 'focused' );
+	viewDocument.render();
+
+	console.log( 'The document was blurred.' );
+} );
+
+setData( viewDocument, '<container:p>{}foo<strong contenteditable="false">bar</strong>baz</container:p>' );
+const viewP = viewRoot.getChild( 0 );
+viewStrong = viewP.getChild( 1 );
+
+viewDocument.focus();

+ 23 - 0
packages/ckeditor5-engine/tests/view/manual/fakeselection.md

@@ -0,0 +1,23 @@
+@bender-ui: collapsed
+@bender-tags: view
+
+## Fake selection
+
+Click on bold `bar` to create fake selection over it before each of following steps:
+   * Press left/up arrow key - collapsed selection should appear before `bar`
+   * Press right/down arrow key - collapsed selection should appear after `bar`
+
+Notes:
+
+- Focus shouldn't disappear from the editable element.
+- No #blur event should be logged on the console.
+- The viewport shouldn't scroll when selecting the `bar`.
+
+-----
+
+Open console and check if `<div style="position: fixed; top: 0px; left: -9999px;">fake selection over bar</div>` is added to editable when fake selection is present. It should be removed when fake selection is not present.
+
+-----
+
+Click on bold `bar` to create fake selection over it. Click outside editable and check if yellow fake selection turns to gray one.
+

+ 136 - 0
packages/ckeditor5-engine/tests/view/observer/fakeselectionobserver.js

@@ -0,0 +1,136 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document */
+
+import FakeSelectionObserver from '/ckeditor5/engine/view/observer/fakeselectionobserver.js';
+import ViewDocument from '/ckeditor5/engine/view/document.js';
+import DomEventData from '/ckeditor5/engine/view/observer/domeventdata.js';
+import { keyCodes } from '/ckeditor5/utils/keyboard.js';
+import { setData, stringify } from '/ckeditor5/engine/dev-utils/view.js';
+
+describe( 'FakeSelectionObserver', () => {
+	let observer;
+	let viewDocument;
+	let root;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		root = viewDocument.createRoot( document.getElementById( 'main' ) );
+		observer = viewDocument.getObserver( FakeSelectionObserver );
+		viewDocument.selection.setFake();
+	} );
+
+	it( 'should do nothing if selection is not fake', () => {
+		viewDocument.selection.setFake( false );
+
+		return checkEventPrevention( keyCodes.arrowleft, false );
+	} );
+
+	it( 'should do nothing if is disabled', () => {
+		observer.disable();
+
+		return checkEventPrevention( keyCodes.arrowleft, false );
+	} );
+
+	it( 'should prevent default for left arrow key', ( ) => {
+		return checkEventPrevention( keyCodes.arrowleft );
+	} );
+
+	it( 'should prevent default for right arrow key', ( ) => {
+		return checkEventPrevention( keyCodes.arrowright );
+	} );
+
+	it( 'should prevent default for up arrow key', ( ) => {
+		return checkEventPrevention( keyCodes.arrowup );
+	} );
+
+	it( 'should prevent default for down arrow key', ( ) => {
+		return checkEventPrevention( keyCodes.arrowdown );
+	} );
+
+	it( 'should fire selectionChange event with new selection when left arrow key is pressed', () => {
+		return checkSelectionChange(
+			'<container:p>foo[<strong>bar</strong>]baz</container:p>',
+			keyCodes.arrowleft,
+			'<container:p>foo[]<strong>bar</strong>baz</container:p>'
+		);
+	} );
+
+	it( 'should fire selectionChange event with new selection when right arrow key is pressed', () => {
+		return checkSelectionChange(
+			'<container:p>foo[<strong>bar</strong>]baz</container:p>',
+			keyCodes.arrowright,
+			'<container:p>foo<strong>bar</strong>[]baz</container:p>'
+		);
+	} );
+
+	it( 'should fire selectionChange event with new selection when up arrow key is pressed', () => {
+		return checkSelectionChange(
+			'<container:p>foo[<strong>bar</strong>]baz</container:p>',
+			keyCodes.arrowup,
+			'<container:p>foo[]<strong>bar</strong>baz</container:p>'
+		);
+	} );
+
+	it( 'should fire selectionChange event with new selection when down arrow key is pressed', () => {
+		return checkSelectionChange(
+			'<container:p>foo[<strong>bar</strong>]baz</container:p>',
+			keyCodes.arrowdown,
+			'<container:p>foo<strong>bar</strong>[]baz</container:p>'
+		);
+	} );
+
+	// Checks if preventDefault method was called by FakeSelectionObserver for specified key code.
+	//
+	// @param {Number} keyCode
+	// @param {Boolean} shouldPrevent If set to true method checks if event was prevented.
+	// @returns {Promise}
+	function checkEventPrevention( keyCode, shouldPrevent = true ) {
+		return new Promise( resolve => {
+			const data = {
+				keyCode,
+				preventDefault: sinon.spy(),
+			};
+
+			viewDocument.once( 'keydown', () => {
+				if ( shouldPrevent ) {
+					sinon.assert.calledOnce( data.preventDefault );
+				} else {
+					sinon.assert.notCalled( data.preventDefault );
+				}
+
+				resolve();
+			}, { priority: 'lowest' } );
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, { target: document.body }, data ) );
+		} );
+	}
+
+	// Checks if proper selectionChange event is fired by FakeSelectionObserver for specified key.
+	//
+	// @param {String} initialData
+	// @param {Number} keyCode
+	// @param {String} output
+	// @returns {Promise}
+	function checkSelectionChange( initialData, keyCode, output ) {
+		return new Promise( resolve => {
+			viewDocument.once( 'selectionChange', ( eventInfo, data ) => {
+				expect( stringify( root.getChild( 0 ), data.newSelection, { showType: true } ) ).to.equal( output );
+				resolve();
+			} );
+
+			setData( viewDocument, initialData );
+			viewDocument.selection.setFake();
+
+			const data = {
+				keyCode,
+				preventDefault: sinon.spy(),
+			};
+
+			viewDocument.fire( 'keydown', new DomEventData( viewDocument, { target: document.body }, data ) );
+		} );
+	}
+} );

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

@@ -117,6 +117,7 @@ describe( 'Renderer', () => {
 			renderer.markedChildren.clear();
 
 			selection.removeAllRanges();
+			selection.setFake( false );
 
 			selectionEditable = viewRoot;
 
@@ -988,6 +989,157 @@ describe( 'Renderer', () => {
 
 			expect( domFocusSpy.called ).to.be.false;
 		} );
+
+		describe( 'fake selection', () => {
+			beforeEach( () => {
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>[foo bar]</container:p>'
+				);
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+			} );
+
+			it( 'should render fake selection', () => {
+				const label = 'fake selection label';
+				selection.setFake( true, { label } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+				expect( domConverter.getCorrespondingViewElement( container ) ).to.be.undefined;
+				expect( container.childNodes.length ).to.equal( 1 );
+				const textNode = container.childNodes[ 0 ];
+				expect( textNode.textContent ).to.equal( label );
+				const domSelection = domRoot.ownerDocument.getSelection();
+				expect( domSelection.anchorNode ).to.equal( textNode );
+				expect( domSelection.anchorOffset ).to.equal( 0 );
+				expect( domSelection.focusNode ).to.equal( textNode );
+				expect( domSelection.focusOffset ).to.equal( label.length );
+			} );
+
+			it( 'should render &nbsp; if no selection label is provided', () => {
+				selection.setFake( true );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+				expect( container.childNodes.length ).to.equal( 1 );
+				const textNode = container.childNodes[ 0 ];
+				expect( textNode.textContent ).to.equal( '\u00A0' );
+				const domSelection = domRoot.ownerDocument.getSelection();
+				expect( domSelection.anchorNode ).to.equal( textNode );
+				expect( domSelection.anchorOffset ).to.equal( 0 );
+				expect( domSelection.focusNode ).to.equal( textNode );
+				expect( domSelection.focusOffset ).to.equal( 1 );
+			} );
+
+			it( 'should remove fake selection container when selection is no longer fake', () => {
+				selection.setFake( true );
+				renderer.render();
+
+				selection.setFake( false );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 1 );
+				const domParagraph = domRoot.childNodes[ 0 ];
+				expect( domParagraph.childNodes.length ).to.equal( 1 );
+				const textNode = domParagraph.childNodes[ 0 ];
+				expect( domParagraph.tagName.toLowerCase() ).to.equal( 'p' );
+				const domSelection = domRoot.ownerDocument.getSelection();
+
+				expect( domSelection.anchorNode ).to.equal( textNode );
+				expect( domSelection.anchorOffset ).to.equal( 0 );
+				expect( domSelection.focusNode ).to.equal( textNode );
+				expect( domSelection.focusOffset ).to.equal( 7 );
+			} );
+
+			it( 'should reuse fake selection container #1', () => {
+				const label = 'fake selection label';
+
+				selection.setFake( true, { label } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+
+				selection.setFake( true, { label } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const newContainer = domRoot.childNodes[ 1 ];
+				expect( newContainer ).equals( container );
+				expect( newContainer.childNodes.length ).to.equal( 1 );
+				const textNode = newContainer.childNodes[ 0 ];
+				expect( textNode.textContent ).to.equal( label );
+			} );
+
+			it( 'should reuse fake selection container #2', () => {
+				selection.setFake( true, { label: 'label 1' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+
+				selection.setFake( false );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 1 );
+
+				selection.setFake( true, { label: 'label 2' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const newContainer = domRoot.childNodes[ 1 ];
+				expect( newContainer ).equals( container );
+				expect( newContainer.childNodes.length ).to.equal( 1 );
+				const textNode = newContainer.childNodes[ 0 ];
+				expect( textNode.textContent ).to.equal( 'label 2' );
+			} );
+
+			it( 'should reuse fake selection container #3', () => {
+				selection.setFake( true, { label: 'label 1' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+
+				selection.setFake( true, { label: 'label 2' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const newContainer = domRoot.childNodes[ 1 ];
+				expect( newContainer ).equals( container );
+				expect( newContainer.childNodes.length ).to.equal( 1 );
+				const textNode = newContainer.childNodes[ 0 ];
+				expect( textNode.textContent ).to.equal( 'label 2' );
+			} );
+
+			it( 'should style fake selection container properly', () => {
+				selection.setFake( true, { label: 'fake selection' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+
+				expect( container.style.position ).to.equal( 'fixed' );
+				expect( container.style.top ).to.equal( '0px' );
+				expect( container.style.left ).to.equal( '-9999px' );
+			} );
+
+			it( 'should bind fake selection container to view selection', () => {
+				selection.setFake( true, { label: 'fake selection' } );
+				renderer.render();
+
+				expect( domRoot.childNodes.length ).to.equal( 2 );
+				const container = domRoot.childNodes[ 1 ];
+
+				const bindSelection = renderer.domConverter.fakeSelectionToView( container );
+				expect( bindSelection ).to.be.defined;
+				expect( bindSelection.isEqual( selection ) ).to.be.true;
+			} );
+		} );
 	} );
 } );
 

+ 84 - 0
packages/ckeditor5-engine/tests/view/selection.js

@@ -460,6 +460,33 @@ describe( 'Selection', () => {
 
 			expect( selection.isEqual( otherSelection ) ).to.be.false;
 		} );
+
+		it( 'should return false if one selection is fake', () => {
+			const otherSelection = new Selection();
+			otherSelection.setFake( true );
+
+			expect( selection.isEqual( otherSelection ) ).to.be.false;
+		} );
+
+		it( 'should return true if both selection are fake', () => {
+			const otherSelection = new Selection();
+			otherSelection.addRange( range1 );
+			otherSelection.setFake( true );
+			selection.setFake( true );
+			selection.addRange( range1 );
+
+			expect( selection.isEqual( otherSelection ) ).to.be.true;
+		} );
+
+		it( 'should return false if both selection are fake but have different label', () => {
+			const otherSelection = new Selection();
+			otherSelection.addRange( range1 );
+			otherSelection.setFake( true , { label: 'foo bar baz' } );
+			selection.setFake( true );
+			selection.addRange( range1 );
+
+			expect( selection.isEqual( otherSelection ) ).to.be.false;
+		} );
 	} );
 
 	describe( 'removeAllRanges', () => {
@@ -538,6 +565,16 @@ describe( 'Selection', () => {
 
 			selection.setTo( otherSelection );
 		} );
+
+		it( 'should set fake state and label', () => {
+			const otherSelection = new Selection();
+			const label = 'foo bar baz';
+			otherSelection.setFake( true, { label } );
+			selection.setTo( otherSelection );
+
+			expect( selection.isFake ).to.be.true;
+			expect( selection.fakeSelectionLabel ).to.equal( label );
+		} );
 	} );
 
 	describe( 'collapse', () => {
@@ -690,4 +727,51 @@ describe( 'Selection', () => {
 			}
 		} );
 	} );
+
+	describe( 'isFake', () => {
+		it( 'should be false for newly created instance', () => {
+			expect( selection.isFake ).to.be.false;
+		} );
+	} );
+
+	describe( 'setFake', () => {
+		it( 'should allow to set selection to fake', () => {
+			selection.setFake( true );
+
+			expect( selection.isFake ).to.be.true;
+		} );
+
+		it( 'should allow to set fake selection label', () => {
+			const label = 'foo bar baz';
+			selection.setFake( true, { label } );
+
+			expect( selection.fakeSelectionLabel ).to.equal( label );
+		} );
+
+		it( 'should not set label when set to false', () => {
+			const label = 'foo bar baz';
+			selection.setFake( false, { label } );
+
+			expect( selection.fakeSelectionLabel ).to.equal( '' );
+		} );
+
+		it( 'should reset label when set to false', () => {
+			const label = 'foo bar baz';
+			selection.setFake( true, { label } );
+			selection.setFake( false );
+
+			expect( selection.fakeSelectionLabel ).to.equal( '' );
+		} );
+
+		it( 'should fire change event', ( done ) => {
+			selection.once( 'change', () => {
+				expect( selection.isFake ).to.be.true;
+				expect( selection.fakeSelectionLabel ).to.equal( 'foo bar baz' );
+
+				done();
+			} );
+
+			selection.setFake( true, { label: 'foo bar baz' } );
+		} );
+	} );
 } );