8
0
Просмотр исходного кода

WIP - DocumentSelection, Selection.

Maciej Bukowski 8 лет назад
Родитель
Сommit
b7f698fb07

+ 1 - 1
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -295,7 +295,7 @@ export function parse( data, schema, options = {} ) {
 
 		// Create new selection.
 		selection = new ModelSelection();
-		selection.setRanges( ranges, viewSelection.isBackward );
+		selection.setTo( ranges, viewSelection.isBackward );
 
 		// Set attributes to selection if specified.
 		if ( options.selectionAttributes ) {

+ 39 - 712
packages/ckeditor5-engine/src/model/documentselection.js

@@ -7,46 +7,10 @@
  * @module engine/model/documentselection
  */
 
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
-import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+import LiveSelection from './liveselection';
 
-import Position from './position';
-import Selection from './selection';
-import Text from './text';
-import TextProxy from './textproxy';
-import Range from './range';
-
-const storePrefix = 'selection:';
-
-const attrOpTypes = new Set(
-	[ 'addAttribute', 'removeAttribute', 'changeAttribute', 'addRootAttribute', 'removeRootAttribute', 'changeRootAttribute' ]
-);
-
-/**
- * `DocumentSelection` is a special selection which is used as the
- * {@link module:engine/model/document~Document#selection document's selection}.
- * There can be only one instance of `DocumentSelection` per document.
- *
- * `DocumentSelection` is automatically updated upon changes in the {@link module:engine/model/document~Document document}
- * to always contain valid ranges. Its attributes are inherited from the text unless set explicitly.
- *
- * Differences between {@link module:engine/model/selection~Selection} and `DocumentSelection` are:
- * * there is always a range in `DocumentSelection` - even if no ranges were added there is a "default range"
- * present in the selection,
- * * ranges added to this selection updates automatically when the document changes,
- * * attributes of `DocumentSelection` are updated automatically according to selection ranges.
- *
- * Since `DocumentSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
- * and is updated when {@link module:engine/model/document~Document document}
- * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
- * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
- * If you need to represent a selection in document fragment,
- * use {@link module:engine/model/selection~Selection Selection class} instead.
- *
- * @extends module:engine/model/selection~Selection
- */
 export default class DocumentSelection {
 	/**
 	 * Creates an empty live selection for given {@link module:engine/model/document~Document}.
@@ -59,246 +23,66 @@ export default class DocumentSelection {
 		 *
 		 * @protected
 		 */
-		this._selection = new Selection();
+		this._selection = new LiveSelection( doc );
 
 		this._selection.delegate( 'change:range' ).to( this );
 		this._selection.delegate( 'change:attribute' ).to( this );
-
-		/**
-		 * Document which owns this selection.
-		 *
-		 * @protected
-		 * @type {module:engine/model/model~Model}
-		 */
-		this._model = doc.model;
-
-		/**
-		 * Document which owns this selection.
-		 *
-		 * @protected
-		 * @type {module:engine/model/document~Document}
-		 */
-		this._document = doc;
-
-		/**
-		 * Keeps mapping of attribute name to priority with which the attribute got modified (added/changed/removed)
-		 * last time. Possible values of priority are: `'low'` and `'normal'`.
-		 *
-		 * Priorities are used by internal `DocumentSelection` mechanisms. All attributes set using `DocumentSelection`
-		 * attributes API are set with `'normal'` priority.
-		 *
-		 * @private
-		 * @type {Map.<String,String>}
-		 */
-		this._attributePriority = new Map();
-
-		// Add events that will ensure selection correctness.
-		this.on( 'change:range', () => {
-			for ( const range of this.getRanges() ) {
-				if ( !this._document._validateSelectionRange( range ) ) {
-					/**
-					 * Range from {@link module:engine/model/documentselection~DocumentSelection document selection}
-					 * starts or ends at incorrect position.
-					 *
-					 * @error document-selection-wrong-position
-					 * @param {module:engine/model/range~Range} range
-					 */
-					throw new CKEditorError(
-						'document-selection-wrong-position: Range from document selection starts or ends at incorrect position.',
-						{ range }
-					);
-				}
-			}
-		} );
-
-		this.listenTo( this._model, 'applyOperation', ( evt, args ) => {
-			const operation = args[ 0 ];
-
-			if ( !operation.isDocumentOperation ) {
-				return;
-			}
-
-			// Whenever attribute operation is performed on document, update selection attributes.
-			// This is not the most efficient way to update selection attributes, but should be okay for now.
-			if ( attrOpTypes.has( operation.type ) ) {
-				this._updateAttributes( false );
-			}
-
-			const batch = operation.delta.batch;
-
-			// Batch may not be passed to the document#change event in some tests.
-			// See https://github.com/ckeditor/ckeditor5-engine/issues/1001#issuecomment-314202352
-			if ( batch ) {
-				// Whenever element which had selection's attributes stored in it stops being empty,
-				// the attributes need to be removed.
-				clearAttributesStoredInElement( operation, this._model, batch );
-			}
-		}, { priority: 'low' } );
 	}
 
-	/**
-	 * @inheritDoc
-	 */
 	get isCollapsed() {
-		return this._selection.rangeCount === 0 ?
-			this._document._getDefaultRange().isCollapsed :
-			this._selection.isCollapsed;
+		return this._selection.isCollapsed;
 	}
 
-	/**
-	 * @inheritDoc
-	 */
 	get anchor() {
-		return this._selection.anchor || this._document._getDefaultRange().start;
+		return this._selection.anchor;
 	}
 
-	/**
-	 * @inheritDoc
-	 */
 	get focus() {
-		return this._selection.focus || this._document._getDefaultRange().end;
+		return this._selection.focus;
 	}
 
-	/**
-	 * @inheritDoc
-	 */
 	get rangeCount() {
-		return this._selection.rangeCount > 0 ? this._selection.rangeCount : 1;
+		return this._selection.rangeCount;
 	}
 
-	/**
-	 * Describes whether `DocumentSelection` has own range(s) set, or if it is defaulted to
-	 * {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
-	 *
-	 * @readonly
-	 * @type {Boolean}
-	 */
 	get hasOwnRange() {
-		return this._selection.rangeCount > 0;
+		return this._selection.hasOwnRange;
 	}
 
 	get isBackward() {
 		return this._selection.isBackward;
 	}
 
-	/**
-	 * Unbinds all events previously bound by document selection.
-	 */
-	destroy() {
-		for ( const range of this._selection._ranges ) {
-			if ( range.detach ) {
-				// TODO
-				range.detach();
-			}
-		}
-
-		this.stopListening();
-	}
-
-	/**
-	 */
-	* getRanges() {
-		if ( this._selection.rangeCount > 0 ) {
-			yield* this._selection.getRanges();
-		} else {
-			yield this._document._getDefaultRange();
-		}
+	getRanges() {
+		return this._selection.getRanges();
 	}
 
-	/**
-	 */
 	getFirstPosition() {
-		return this.getFirstRange().start;
+		return this._selection.getFirstPosition();
 	}
 
-	/**
-	 */
 	getFirstRange() {
-		return this._selection.getFirstRange() || this._document._getDefaultRange();
+		return this._selection.getFirstRange();
 	}
 
-	/**
-	 */
 	getLastRange() {
-		return this._selection.getLastRange() || this._document._getDefaultRange();
+		return this._selection.getLastRange();
 	}
 
-	/**
-	 * @protected
-	 * @param {*} itemOrPosition
-	 * @param {*} offset
-	 */
 	_moveFocusTo( itemOrPosition, offset ) {
-		const newFocus = Position.createAt( itemOrPosition, offset );
-
-		if ( newFocus.compareWith( this.focus ) == 'same' ) {
-			return;
-		}
-
-		const anchor = this.anchor;
-
-		if ( this._selection.length ) {
-			this._selection._popRange();
-		}
-
-		if ( newFocus.compareWith( anchor ) == 'before' ) {
-			this._selection.addRange( new Range( newFocus, anchor ), true );
-		} else {
-			this._selection.addRange( new Range( anchor, newFocus ) );
-		}
+		this._selection.moveFocusTo( itemOrPosition, offset );
 	}
 
-	/**
-	 * @protected
-	 * @param {*} selectable
-	 */
-	_setTo( selectable, isBackward ) {
-		this._selection.setTo( selectable, isBackward );
-		this._refreshAttributes();
+	_setTo( selectable, backwardSelectionOrOffset ) {
+		this._selection.setTo( selectable, backwardSelectionOrOffset );
 	}
 
-	/**
-	 * @protected
-	 * @param {String} key
-	 * @param {*} value
-	 */
 	_setAttribute( key, value ) {
-		// Store attribute in parent element if the selection is collapsed in an empty node.
-		if ( this.isCollapsed && this._selection.anchor.parent.isEmpty ) {
-			this._storeAttribute( key, value );
-		}
-
-		if ( this._setAttribute2( key, value ) ) {
-			// Fire event with exact data.
-			const attributeKeys = [ key ];
-			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
-		}
+		this._selection.setAttribute( key, value );
 	}
 
-	/**
-	 * @private
-	 */
-	_removeAllRanges() {
-		this._selection.removeAllRanges();
-		this._refreshAttributes();
-	}
-
-	/**
-	 * Should be used only by the {@link module:engine/model/writer~Writer} class.
-	 *
-	 * @protected
-	 */
-	_removeAttribute( key ) {
-		// Remove stored attribute from parent element if the selection is collapsed in an empty node.
-		if ( this.isCollapsed && this.anchor.parent.isEmpty ) {
-			this._removeStoredAttribute( key );
-		}
-
-		if ( this._removeAttributeByDirectChange( key ) ) {
-			// Fire event with exact data.
-			const attributeKeys = [ key ];
-			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
-		}
+	destroy() {
+		this._selection.destroy();
 	}
 
 	getAttributes() {
@@ -309,171 +93,40 @@ export default class DocumentSelection {
 		return this._selection.getAttribute( key );
 	}
 
-	/**
-	 * @inheritDoc
-	 */
-	// TODO: Remove: for ( attr ) removeAttribute; setAttribute( newAttrs );
-	_setAttributesTo( attrs ) {
-		attrs = toMap( attrs );
-
-		if ( this.isCollapsed && this.anchor.parent.isEmpty ) {
-			this._setStoredAttributesTo( attrs );
-		}
-
-		const changed = this.__setAttributesTo( attrs );
-
-		if ( changed.size > 0 ) {
-			// Fire event with exact data (fire only if anything changed).
-			const attributeKeys = Array.from( changed );
-			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
-		}
+	hasAttribute( key ) {
+		return this._selection.hasAttribute( key );
 	}
 
-	/**
-	 * @inheritDoc
-	 */
-	_clearAttributes() {
-		this._setAttributesTo( [] );
+	getSelectedBlocks() {
+		return this._selection.getSelectedBlocks();
 	}
 
-	/**
-	 * Removes all attributes from the selection and sets attributes according to the surrounding nodes.
-	 *
-	 * @private
-	 */
-	_refreshAttributes() {
-		this._updateAttributes( true );
+	containsEntireContent( element ) {
+		return this._selection.containsEntireContent( element );
 	}
 
-	/**
-	 * This method is not available in `DocumentSelection`. There can be only one
-	 * `DocumentSelection` per document instance, so creating new `DocumentSelection`s this way
-	 * would be unsafe.
-	 */
-	static createFromSelection() {
-		/**
-		 * Cannot create a new `DocumentSelection` instance.
-		 *
-		 * `DocumentSelection#createFromSelection()` is not available. There can be only one
-		 * `DocumentSelection` per document instance, so creating new `DocumentSelection`s this way
-		 * would be unsafe.
-		 *
-		 * @error documentselection-cannot-create
-		 */
-		throw new CKEditorError( 'documentselection-cannot-create: Cannot create a new DocumentSelection instance.' );
+	getLastPosition() {
+		return this._selection.getLastPosition();
 	}
 
-	// /**
-	//  * Prepares given range to be added to selection. Checks if it is correct,
-	//  * converts it to {@link module:engine/model/liverange~LiveRange LiveRange}
-	//  * and sets listeners listening to the range's change event.
-	//  *
-	//  * @private
-	//  * @param {module:engine/model/range~Range} range
-	//  */
-	// _prepareRange( range ) {
-	// 	if ( !( range instanceof Range ) ) {
-	// 		/**
-	// 		 * Trying to add an object that is not an instance of Range.
-	// 		 *
-	// 		 * @error model-selection-added-not-range
-	// 		 */
-	// 		throw new CKEditorError( 'model-selection-added-not-range: Trying to add an object that is not an instance of Range.' );
-	// 	}
-
-	// 	if ( range.root == this._document.graveyard ) {
-	// 		/**
-	// 		 * Trying to add a Range that is in the graveyard root. Range rejected.
-	// 		 *
-	// 		 * @warning model-selection-range-in-graveyard
-	// 		 */
-	// 		log.warn( 'model-selection-range-in-graveyard: Trying to add a Range that is in the graveyard root. Range rejected.' );
-
-	// 		return;
-	// 	}
-
-	// 	this._checkRange( range );
-
-	// 	const liveRange = LiveRange.createFromRange( range );
-
-	// 	liveRange.on( 'change:range', ( evt, oldRange, data ) => {
-	// 		// If `LiveRange` is in whole moved to the graveyard, fix that range.
-	// 		if ( liveRange.root == this._document.graveyard ) {
-	// 			this._fixGraveyardSelection( liveRange, data.sourcePosition );
-	// 		}
-
-	// 		// Whenever a live range from selection changes, fire an event informing about that change.
-	// 		this.fire( 'change:range', { directChange: false } );
-	// 	} );
-
-	// 	return liveRange;
-	// }
-
-	/**
-	 * Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
-	 *
-	 * @private
-	 * @param {Boolean} clearAll
-	 * @fires change:attribute
-	 */
-	_updateAttributes( clearAll ) {
-		const newAttributes = toMap( this._getSurroundingAttributes() );
-		const oldAttributes = toMap( this.getAttributes() );
-
-		if ( clearAll ) {
-			// If `clearAll` remove all attributes and reset priorities.
-			this._attributePriority = new Map();
-			this._selection._attrs = new Map();
-		} else {
-			// If not, remove only attributes added with `low` priority.
-			for ( const [ key, priority ] of this._attributePriority ) {
-				if ( priority == 'low' ) {
-					this._selection._attrs.delete( key );
-					this._attributePriority.delete( key );
-				}
-			}
-		}
-
-		this.__setAttributesTo( newAttributes, false );
-
-		// Let's evaluate which attributes really changed.
-		const changed = [];
-
-		// First, loop through all attributes that are set on selection right now.
-		// Check which of them are different than old attributes.
-		for ( const [ newKey, newValue ] of this.getAttributes() ) {
-			if ( !oldAttributes.has( newKey ) || oldAttributes.get( newKey ) !== newValue ) {
-				changed.push( newKey );
-			}
-		}
-
-		// Then, check which of old attributes got removed.
-		for ( const [ oldKey ] of oldAttributes ) {
-			if ( !this.hasAttribute( oldKey ) ) {
-				changed.push( oldKey );
-			}
-		}
-
-		// Fire event with exact data (fire only if anything changed).
-		if ( changed.length > 0 ) {
-			this.fire( 'change:attribute', { attributeKeys: changed, directChange: false } );
-		}
+	_clearAttributes() {
+		this._selection.clearAttributes();
 	}
 
-	hasAttribute( key ) {
-		return this._selection.hasAttribute( key );
+	_removeAllRanges() {
+		this._selection.removeAllRanges();
 	}
 
-	getSelectedBlocks() {
-		return this._selection.getSelectedBlocks.call( this );
+	_removeAttribute( param ) {
+		this._selection.removeAttribute( param );
 	}
 
-	containsEntireContent() {
-		return this._selection.containsEntireContent.call( this );
+	_getStoredAttributes() {
+		return this._selection._getStoredAttributes();
 	}
 
-	getLastPosition() {
-		return this._selection.getLastPosition.call( this );
+	_setAttributesTo( attrs ) {
+		return this._selection.setAttributesTo( attrs );
 	}
 
 	/**
@@ -484,7 +137,7 @@ export default class DocumentSelection {
 	 * @returns {String} Converted attribute key, applicable for selection store.
 	 */
 	static _getStoreAttributeKey( key ) {
-		return storePrefix + key;
+		return LiveSelection._getStoreAttributeKey( key );
 	}
 
 	/**
@@ -494,335 +147,9 @@ export default class DocumentSelection {
 	 * @param {String} key
 	 * @returns {Boolean}
 	 */
-	static _isStoreAttributeKey( key ) {
-		return key.startsWith( storePrefix );
-	}
-
-	/**
-	 * Internal method for setting `DocumentSelection` attribute. Supports attribute priorities (through `directChange`
-	 * parameter).
-	 *
-	 * @private
-	 * @param {String} key Attribute key.
-	 * @param {*} value Attribute value.
-	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
-	 * is caused by `Batch` API.
-	 * @returns {Boolean} Whether value has changed.
-	 */
-	_setAttribute2( key, value, directChange = true ) {
-		const priority = directChange ? 'normal' : 'low';
-
-		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
-			// Priority too low.
-			return false;
-		}
-
-		const oldValue = this._selection.getAttribute( key );
-
-		// Don't do anything if value has not changed.
-		if ( oldValue === value ) {
-			return false;
-		}
-
-		this._selection._attrs.set( key, value );
-
-		// Update priorities map.
-		this._attributePriority.set( key, priority );
-
-		return true;
-	}
-
-	/**
-	 * Internal method for removing `DocumentSelection` attribute. Supports attribute priorities (through `directChange`
-	 * parameter).
-	 *
-	 * @private
-	 * @param {String} key Attribute key.
-	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
-	 * is caused by `Batch` API.
-	 * @returns {Boolean} Whether attribute was removed. May not be true if such attributes didn't exist or the
-	 * existing attribute had higher priority.
-	 */
-	_removeAttributeByDirectChange( key, directChange = true ) {
-		const priority = directChange ? 'normal' : 'low';
-
-		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
-			// Priority too low.
-			return false;
-		}
-
-		// Don't do anything if value has not changed.
-		if ( !this._selection.hasAttribute( key ) ) {
-			return false;
-		}
-
-		this._selection._attrs.delete( key );
-
-		// Update priorities map.
-		this._attributePriority.set( key, priority );
-
-		return true;
-	}
-
-	/**
-	 * Internal method for setting multiple `DocumentSelection` attributes. Supports attribute priorities (through
-	 * `directChange` parameter).
-	 *
-	 * @private
-	 * @param {Map} attrs Iterable object containing attributes to be set.
-	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
-	 * is caused by `Batch` API.
-	 * @returns {Set.<String>} Changed attribute keys.
-	 */
-	__setAttributesTo( attrs, directChange = true ) {
-		const changed = new Set();
-
-		for ( const [ oldKey, oldValue ] of this.getAttributes() ) {
-			// Do not remove attribute if attribute with same key and value is about to be set.
-			if ( attrs.get( oldKey ) === oldValue ) {
-				continue;
-			}
-
-			// Attribute still might not get removed because of priorities.
-			if ( this._removeAttributeByDirectChange( oldKey, directChange ) ) {
-				changed.add( oldKey );
-			}
-		}
-
-		for ( const [ key, value ] of attrs ) {
-			// Attribute may not be set because of attributes or because same key/value is already added.
-			const gotAdded = this._setAttribute2( key, value, directChange );
-
-			if ( gotAdded ) {
-				changed.add( key );
-			}
-		}
-
-		return changed;
-	}
-
-	/**
-	 * Returns an iterator that iterates through all selection attributes stored in current selection's parent.
-	 *
-	 * @private
-	 * @returns {Iterable.<*>}
-	 */
-	* _getStoredAttributes() {
-		const selectionParent = this.getFirstPosition().parent;
-
-		if ( this.isCollapsed && selectionParent.isEmpty ) {
-			for ( const key of selectionParent.getAttributeKeys() ) {
-				if ( key.startsWith( storePrefix ) ) {
-					const realKey = key.substr( storePrefix.length );
-
-					yield [ realKey, selectionParent.getAttribute( key ) ];
-				}
-			}
-		}
-	}
-
-	/**
-	 * Removes attribute with given key from attributes stored in current selection's parent node.
-	 *
-	 * @private
-	 * @param {String} key Key of attribute to remove.
-	 */
-	_removeStoredAttribute( key ) {
-		const storeKey = DocumentSelection._getStoreAttributeKey( key );
-
-		this._model.change( writer => {
-			writer.removeAttribute( storeKey, this.anchor.parent );
-		} );
-	}
-
-	/**
-	 * Stores given attribute key and value in current selection's parent node.
-	 *
-	 * @private
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
-	 */
-	_storeAttribute( key, value ) {
-		const storeKey = DocumentSelection._getStoreAttributeKey( key );
-
-		this._model.change( writer => {
-			writer.setAttribute( storeKey, value, this.anchor.parent );
-		} );
-	}
-
-	/**
-	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
-	 *
-	 * @private
-	 * @param {Iterable} attrs Iterable object containing attributes to be set.
-	 */
-	_setStoredAttributesTo( attrs ) {
-		const selectionParent = this.anchor.parent;
-
-		this._model.change( writer => {
-			for ( const [ oldKey ] of this._getStoredAttributes() ) {
-				const storeKey = DocumentSelection._getStoreAttributeKey( oldKey );
-
-				writer.removeAttribute( storeKey, selectionParent );
-			}
-
-			for ( const [ key, value ] of attrs ) {
-				const storeKey = DocumentSelection._getStoreAttributeKey( key );
-
-				writer.setAttribute( storeKey, value, selectionParent );
-			}
-		} );
-	}
-
-	/**
-	 * Checks model text nodes that are closest to the selection's first position and returns attributes of first
-	 * found element. If there are no text nodes in selection's first position parent, it returns selection
-	 * attributes stored in that parent.
-	 *
-	 * @private
-	 * @returns {Iterable.<*>} Collection of attributes.
-	 */
-	_getSurroundingAttributes() {
-		const position = this.getFirstPosition();
-		const schema = this._model.schema;
-
-		let attrs = null;
-
-		if ( !this.isCollapsed ) {
-			// 1. If selection is a range...
-			const range = this.getFirstRange();
-
-			// ...look for a first character node in that range and take attributes from it.
-			for ( const value of range ) {
-				// If the item is an object, we don't want to get attributes from its children.
-				if ( value.item.is( 'element' ) && schema.isObject( value.item ) ) {
-					break;
-				}
-
-				// This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
-				// It can be done better by using `break;` instead of checking `attrs === null`.
-				if ( value.type == 'text' && attrs === null ) {
-					attrs = value.item.getAttributes();
-				}
-			}
-		} else {
-			// 2. If the selection is a caret or the range does not contain a character node...
-
-			const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
-			const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
-
-			// ...look at the node before caret and take attributes from it if it is a character node.
-			attrs = getAttrsIfCharacter( nodeBefore );
-
-			// 3. If not, look at the node after caret...
-			if ( !attrs ) {
-				attrs = getAttrsIfCharacter( nodeAfter );
-			}
-
-			// 4. If not, try to find the first character on the left, that is in the same node.
-			if ( !attrs ) {
-				let node = nodeBefore;
-
-				while ( node && !attrs ) {
-					node = node.previousSibling;
-					attrs = getAttrsIfCharacter( node );
-				}
-			}
-
-			// 5. If not found, try to find the first character on the right, that is in the same node.
-			if ( !attrs ) {
-				let node = nodeAfter;
-
-				while ( node && !attrs ) {
-					node = node.nextSibling;
-					attrs = getAttrsIfCharacter( node );
-				}
-			}
-
-			// 6. If not found, selection should retrieve attributes from parent.
-			if ( !attrs ) {
-				attrs = this._getStoredAttributes();
-			}
-		}
-
-		return attrs;
-	}
-
-	/**
-	 * Fixes a selection range after it ends up in graveyard root.
-	 *
-	 * @private
-	 * @param {module:engine/model/liverange~LiveRange} liveRange The range from selection, that ended up in the graveyard root.
-	 * @param {module:engine/model/position~Position} removedRangeStart Start position of a range which was removed.
-	 */
-	_fixGraveyardSelection( liveRange, removedRangeStart ) {
-		// The start of the removed range is the closest position to the `liveRange` - the original selection range.
-		// This is a good candidate for a fixed selection range.
-		const positionCandidate = Position.createFromPosition( removedRangeStart );
-
-		// Find a range that is a correct selection range and is closest to the start of removed range.
-		const selectionRange = this._document.getNearestSelectionRange( positionCandidate );
-
-		// Remove the old selection range before preparing and adding new selection range. This order is important,
-		// because new range, in some cases, may intersect with old range (it depends on `getNearestSelectionRange()` result).
-		const index = this._ranges.indexOf( liveRange );
-		this._ranges.splice( index, 1 );
-		liveRange.detach();
-
-		// If nearest valid selection range has been found - add it in the place of old range.
-		if ( selectionRange ) {
-			// Check the range, convert it to live range, bind events, etc.
-			const newRange = this._prepareRange( selectionRange );
-
-			// Add new range in the place of old range.
-			this._ranges.splice( index, 0, newRange );
-		}
-		// If nearest valid selection range cannot be found - just removing the old range is fine.
-
-		// Fire an event informing about selection change.
-		this.fire( 'change:range', { directChange: false } );
+	static _isStoreAttributeKey( storePrefix ) {
+		return LiveSelection._isStoreAttributeKey( storePrefix );
 	}
 }
 
 mix( DocumentSelection, EmitterMixin );
-
-/**
- * @event change:attribute
- */
-
-// Helper function for {@link module:engine/model/documentselection~DocumentSelection#_updateAttributes}.
-//
-// It takes model item, checks whether it is a text node (or text proxy) and, if so, returns it's attributes. If not, returns `null`.
-//
-// @param {module:engine/model/item~Item|null}  node
-// @returns {Boolean|Iterable}
-function getAttrsIfCharacter( node ) {
-	if ( node instanceof TextProxy || node instanceof Text ) {
-		return node.getAttributes();
-	}
-
-	return null;
-}
-
-// Removes selection attributes from element which is not empty anymore.
-function clearAttributesStoredInElement( operation, model, batch ) {
-	let changeParent = null;
-
-	if ( operation.type == 'insert' ) {
-		changeParent = operation.position.parent;
-	} else if ( operation.type == 'move' || operation.type == 'reinsert' || operation.type == 'remove' ) {
-		changeParent = operation.getMovedRangeStart().parent;
-	}
-
-	if ( !changeParent || changeParent.isEmpty ) {
-		return;
-	}
-
-	model.enqueueChange( batch, writer => {
-		const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
-
-		for ( const key of storedAttributes ) {
-			writer.removeAttribute( key, changeParent );
-		}
-	} );
-}

+ 725 - 0
packages/ckeditor5-engine/src/model/liveselection.js

@@ -0,0 +1,725 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/liveselection
+ */
+
+import Position from './position';
+import Range from './range';
+import LiveRange from './liverange';
+import Text from './text';
+import TextProxy from './textproxy';
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+import Selection from './selection';
+
+const storePrefix = 'selection:';
+
+const attrOpTypes = new Set(
+	[ 'addAttribute', 'removeAttribute', 'changeAttribute', 'addRootAttribute', 'removeRootAttribute', 'changeRootAttribute' ]
+);
+
+export default class LiveSelection extends Selection {
+	/**
+	 * Creates an empty live selection for given {@link module:engine/model/document~Document}.
+	 *
+	 * @param {module:engine/model/document~Document} doc Document which owns this selection.
+	 */
+	constructor( doc ) {
+		super();
+
+		/**
+		 * Document which owns this selection.
+		 *
+		 * @protected
+		 * @member {module:engine/model/model~Model}
+		 */
+		this._model = doc.model;
+
+		/**
+		 * Document which owns this selection.
+		 *
+		 * @protected
+		 * @member {module:engine/model/document~Document}
+		 */
+		this._document = doc;
+
+		/**
+		 * Keeps mapping of attribute name to priority with which the attribute got modified (added/changed/removed)
+		 * last time. Possible values of priority are: `'low'` and `'normal'`.
+		 *
+		 * Priorities are used by internal `LiveSelection` mechanisms. All attributes set using `LiveSelection`
+		 * attributes API are set with `'normal'` priority.
+		 *
+		 * @private
+		 * @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
+		 */
+		this._attributePriority = new Map();
+
+		this.listenTo( this._document, 'change', ( evt, type, changes, batch ) => {
+			// Whenever attribute operation is performed on document, update selection attributes.
+			// This is not the most efficient way to update selection attributes, but should be okay for now.
+			if ( attrOpTypes.has( type ) ) {
+				this._updateAttributes( false );
+			}
+
+			// Batch may not be passed to the document#change event in some tests.
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/1001#issuecomment-314202352
+			// Ignore also transparent batches because they are... transparent.
+			if ( batch && batch.type !== 'transparent' ) {
+				// Whenever element which had selection's attributes stored in it stops being empty,
+				// the attributes need to be removed.
+				clearAttributesStoredInElement( changes, this._model, batch );
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get isCollapsed() {
+		const length = this._ranges.length;
+
+		return length === 0 ? this._document._getDefaultRange().isCollapsed : super.isCollapsed;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get anchor() {
+		return super.anchor || this._document._getDefaultRange().start;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get focus() {
+		return super.focus || this._document._getDefaultRange().end;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get rangeCount() {
+		return this._ranges.length ? this._ranges.length : 1;
+	}
+
+	/**
+	 * Describes whether `LiveSelection` has own range(s) set, or if it is defaulted to
+	 * {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
+	 *
+	 * @readonly
+	 * @type {Boolean}
+	 */
+	get hasOwnRange() {
+		return this._ranges.length > 0;
+	}
+
+	/**
+	 * Unbinds all events previously bound by document selection.
+	 */
+	destroy() {
+		for ( let i = 0; i < this._ranges.length; i++ ) {
+			this._ranges[ i ].detach();
+		}
+
+		this.stopListening();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	* getRanges() {
+		if ( this._ranges.length ) {
+			yield* super.getRanges();
+		} else {
+			yield this._document._getDefaultRange();
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	getFirstRange() {
+		return super.getFirstRange() || this._document._getDefaultRange();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	getLastRange() {
+		return super.getLastRange() || this._document._getDefaultRange();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	addRange( range, isBackward = false ) {
+		super.addRange( range, isBackward );
+		this.refreshAttributes();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	removeAllRanges() {
+		super.removeAllRanges();
+		this.refreshAttributes();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_setRanges( newRanges, isLastBackward = false ) {
+		super._setRanges( newRanges, isLastBackward );
+		this.refreshAttributes();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	setAttribute( key, value ) {
+		// Store attribute in parent element if the selection is collapsed in an empty node.
+		if ( this.isCollapsed && this.anchor.parent.isEmpty ) {
+			this._storeAttribute( key, value );
+		}
+
+		if ( this._setAttribute( key, value ) ) {
+			// Fire event with exact data.
+			const attributeKeys = [ key ];
+			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	removeAttribute( key ) {
+		// Remove stored attribute from parent element if the selection is collapsed in an empty node.
+		if ( this.isCollapsed && this.anchor.parent.isEmpty ) {
+			this._removeStoredAttribute( key );
+		}
+
+		if ( this._removeAttribute( key ) ) {
+			// Fire event with exact data.
+			const attributeKeys = [ key ];
+			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	setAttributesTo( attrs ) {
+		attrs = toMap( attrs );
+
+		if ( this.isCollapsed && this.anchor.parent.isEmpty ) {
+			this._setStoredAttributesTo( attrs );
+		}
+
+		const changed = this._setAttributesTo( attrs );
+
+		if ( changed.size > 0 ) {
+			// Fire event with exact data (fire only if anything changed).
+			const attributeKeys = Array.from( changed );
+			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	clearAttributes() {
+		this.setAttributesTo( [] );
+	}
+
+	/**
+	 * Removes all attributes from the selection and sets attributes according to the surrounding nodes.
+	 */
+	refreshAttributes() {
+		this._updateAttributes( true );
+	}
+
+	/**
+	 * This method is not available in `LiveSelection`. There can be only one
+	 * `LiveSelection` per document instance, so creating new `LiveSelection`s this way
+	 * would be unsafe.
+	 */
+	static createFromSelection() {
+		/**
+		 * Cannot create a new `LiveSelection` instance.
+		 *
+		 * `LiveSelection#createFromSelection()` is not available. There can be only one
+		 * `LiveSelection` per document instance, so creating new `LiveSelection`s this way
+		 * would be unsafe.
+		 *
+		 * @error liveselection-cannot-create
+		 */
+		throw new CKEditorError( 'liveselection-cannot-create: Cannot create a new LiveSelection instance.' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_popRange() {
+		this._ranges.pop().detach();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_pushRange( range ) {
+		const liveRange = this._prepareRange( range );
+
+		// `undefined` is returned when given `range` is in graveyard root.
+		if ( liveRange ) {
+			this._ranges.push( liveRange );
+		}
+	}
+
+	/**
+	 * Prepares given range to be added to selection. Checks if it is correct,
+	 * converts it to {@link module:engine/model/liverange~LiveRange LiveRange}
+	 * and sets listeners listening to the range's change event.
+	 *
+	 * @private
+	 * @param {module:engine/model/range~Range} range
+	 */
+	_prepareRange( range ) {
+		if ( !( range instanceof Range ) ) {
+			/**
+			 * Trying to add an object that is not an instance of Range.
+			 *
+			 * @error model-selection-added-not-range
+			 */
+			throw new CKEditorError( 'model-selection-added-not-range: Trying to add an object that is not an instance of Range.' );
+		}
+
+		if ( range.root == this._document.graveyard ) {
+			/**
+			 * Trying to add a Range that is in the graveyard root. Range rejected.
+			 *
+			 * @warning model-selection-range-in-graveyard
+			 */
+			log.warn( 'model-selection-range-in-graveyard: Trying to add a Range that is in the graveyard root. Range rejected.' );
+
+			return;
+		}
+
+		this._checkRange( range );
+
+		const liveRange = LiveRange.createFromRange( range );
+
+		liveRange.on( 'change:range', ( evt, oldRange, data ) => {
+			// If `LiveRange` is in whole moved to the graveyard, fix that range.
+			if ( liveRange.root == this._document.graveyard ) {
+				this._fixGraveyardSelection( liveRange, data.sourcePosition );
+			}
+
+			// Whenever a live range from selection changes, fire an event informing about that change.
+			this.fire( 'change:range', { directChange: false } );
+		} );
+
+		return liveRange;
+	}
+
+	/**
+	 * Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
+	 *
+	 * @protected
+	 * @param {Boolean} clearAll
+	 * @fires change:attribute
+	 */
+	_updateAttributes( clearAll ) {
+		const newAttributes = toMap( this._getSurroundingAttributes() );
+		const oldAttributes = toMap( this.getAttributes() );
+
+		if ( clearAll ) {
+			// If `clearAll` remove all attributes and reset priorities.
+			this._attributePriority = new Map();
+			this._attrs = new Map();
+		} else {
+			// If not, remove only attributes added with `low` priority.
+			for ( const [ key, priority ] of this._attributePriority ) {
+				if ( priority == 'low' ) {
+					this._attrs.delete( key );
+					this._attributePriority.delete( key );
+				}
+			}
+		}
+
+		this._setAttributesTo( newAttributes, false );
+
+		// Let's evaluate which attributes really changed.
+		const changed = [];
+
+		// First, loop through all attributes that are set on selection right now.
+		// Check which of them are different than old attributes.
+		for ( const [ newKey, newValue ] of this.getAttributes() ) {
+			if ( !oldAttributes.has( newKey ) || oldAttributes.get( newKey ) !== newValue ) {
+				changed.push( newKey );
+			}
+		}
+
+		// Then, check which of old attributes got removed.
+		for ( const [ oldKey ] of oldAttributes ) {
+			if ( !this.hasAttribute( oldKey ) ) {
+				changed.push( oldKey );
+			}
+		}
+
+		// Fire event with exact data (fire only if anything changed).
+		if ( changed.length > 0 ) {
+			this.fire( 'change:attribute', { attributeKeys: changed, directChange: false } );
+		}
+	}
+
+	/**
+	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
+	 *
+	 * @protected
+	 * @param {String} key Attribute key to convert.
+	 * @returns {String} Converted attribute key, applicable for selection store.
+	 */
+	static _getStoreAttributeKey( key ) {
+		return storePrefix + key;
+	}
+
+	/**
+	 * Checks whether the given attribute key is an attribute stored on an element.
+	 *
+	 * @protected
+	 * @param {String} key
+	 * @returns {Boolean}
+	 */
+	static _isStoreAttributeKey( key ) {
+		return key.startsWith( storePrefix );
+	}
+
+	/**
+	 * Internal method for setting `LiveSelection` attribute. Supports attribute priorities (through `directChange`
+	 * parameter).
+	 *
+	 * @private
+	 * @param {String} key Attribute key.
+	 * @param {*} value Attribute value.
+	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
+	 * is caused by `Batch` API.
+	 * @returns {Boolean} Whether value has changed.
+	 */
+	_setAttribute( key, value, directChange = true ) {
+		const priority = directChange ? 'normal' : 'low';
+
+		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
+			// Priority too low.
+			return false;
+		}
+
+		const oldValue = super.getAttribute( key );
+
+		// Don't do anything if value has not changed.
+		if ( oldValue === value ) {
+			return false;
+		}
+
+		this._attrs.set( key, value );
+
+		// Update priorities map.
+		this._attributePriority.set( key, priority );
+
+		return true;
+	}
+
+	/**
+	 * Internal method for removing `LiveSelection` attribute. Supports attribute priorities (through `directChange`
+	 * parameter).
+	 *
+	 * @private
+	 * @param {String} key Attribute key.
+	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
+	 * is caused by `Batch` API.
+	 * @returns {Boolean} Whether attribute was removed. May not be true if such attributes didn't exist or the
+	 * existing attribute had higher priority.
+	 */
+	_removeAttribute( key, directChange = true ) {
+		const priority = directChange ? 'normal' : 'low';
+
+		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
+			// Priority too low.
+			return false;
+		}
+
+		// Don't do anything if value has not changed.
+		if ( !super.hasAttribute( key ) ) {
+			return false;
+		}
+
+		this._attrs.delete( key );
+
+		// Update priorities map.
+		this._attributePriority.set( key, priority );
+
+		return true;
+	}
+
+	/**
+	 * Internal method for setting multiple `LiveSelection` attributes. Supports attribute priorities (through
+	 * `directChange` parameter).
+	 *
+	 * @private
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 * @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
+	 * is caused by `Batch` API.
+	 * @returns {Set.<String>} Changed attribute keys.
+	 */
+	_setAttributesTo( attrs, directChange = true ) {
+		const changed = new Set();
+
+		for ( const [ oldKey, oldValue ] of this.getAttributes() ) {
+			// Do not remove attribute if attribute with same key and value is about to be set.
+			if ( attrs.get( oldKey ) === oldValue ) {
+				continue;
+			}
+
+			// Attribute still might not get removed because of priorities.
+			if ( this._removeAttribute( oldKey, directChange ) ) {
+				changed.add( oldKey );
+			}
+		}
+
+		for ( const [ key, value ] of attrs ) {
+			// Attribute may not be set because of attributes or because same key/value is already added.
+			const gotAdded = this._setAttribute( key, value, directChange );
+
+			if ( gotAdded ) {
+				changed.add( key );
+			}
+		}
+
+		return changed;
+	}
+
+	/**
+	 * Returns an iterator that iterates through all selection attributes stored in current selection's parent.
+	 *
+	 * @private
+	 * @returns {Iterable.<*>}
+	 */
+	* _getStoredAttributes() {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.isEmpty ) {
+			for ( const key of selectionParent.getAttributeKeys() ) {
+				if ( key.startsWith( storePrefix ) ) {
+					const realKey = key.substr( storePrefix.length );
+
+					yield [ realKey, selectionParent.getAttribute( key ) ];
+				}
+			}
+		}
+	}
+
+	/**
+	 * Removes attribute with given key from attributes stored in current selection's parent node.
+	 *
+	 * @private
+	 * @param {String} key Key of attribute to remove.
+	 */
+	_removeStoredAttribute( key ) {
+		const storeKey = LiveSelection._getStoreAttributeKey( key );
+
+		this._model.change( writer => {
+			writer.removeAttribute( storeKey, this.anchor.parent );
+		} );
+	}
+
+	/**
+	 * Stores given attribute key and value in current selection's parent node.
+	 *
+	 * @private
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	_storeAttribute( key, value ) {
+		const storeKey = LiveSelection._getStoreAttributeKey( key );
+
+		this._model.change( writer => {
+			writer.setAttribute( storeKey, value, this.anchor.parent );
+		} );
+	}
+
+	/**
+	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
+	 *
+	 * @private
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 */
+	_setStoredAttributesTo( attrs ) {
+		const selectionParent = this.anchor.parent;
+
+		this._model.change( writer => {
+			for ( const [ oldKey ] of this._getStoredAttributes() ) {
+				const storeKey = LiveSelection._getStoreAttributeKey( oldKey );
+
+				writer.removeAttribute( storeKey, selectionParent );
+			}
+
+			for ( const [ key, value ] of attrs ) {
+				const storeKey = LiveSelection._getStoreAttributeKey( key );
+
+				writer.setAttribute( storeKey, value, selectionParent );
+			}
+		} );
+	}
+
+	/**
+	 * Checks model text nodes that are closest to the selection's first position and returns attributes of first
+	 * found element. If there are no text nodes in selection's first position parent, it returns selection
+	 * attributes stored in that parent.
+	 *
+	 * @private
+	 * @returns {Iterable.<*>} Collection of attributes.
+	 */
+	_getSurroundingAttributes() {
+		const position = this.getFirstPosition();
+		const schema = this._model.schema;
+
+		let attrs = null;
+
+		if ( !this.isCollapsed ) {
+			// 1. If selection is a range...
+			const range = this.getFirstRange();
+
+			// ...look for a first character node in that range and take attributes from it.
+			for ( const value of range ) {
+				// If the item is an object, we don't want to get attributes from its children.
+				if ( value.item.is( 'element' ) && schema.isObject( value.item ) ) {
+					break;
+				}
+
+				// This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
+				// It can be done better by using `break;` instead of checking `attrs === null`.
+				if ( value.type == 'text' && attrs === null ) {
+					attrs = value.item.getAttributes();
+				}
+			}
+		} else {
+			// 2. If the selection is a caret or the range does not contain a character node...
+
+			const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
+			const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
+
+			// ...look at the node before caret and take attributes from it if it is a character node.
+			attrs = getAttrsIfCharacter( nodeBefore );
+
+			// 3. If not, look at the node after caret...
+			if ( !attrs ) {
+				attrs = getAttrsIfCharacter( nodeAfter );
+			}
+
+			// 4. If not, try to find the first character on the left, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeBefore;
+
+				while ( node && !attrs ) {
+					node = node.previousSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 5. If not found, try to find the first character on the right, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeAfter;
+
+				while ( node && !attrs ) {
+					node = node.nextSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 6. If not found, selection should retrieve attributes from parent.
+			if ( !attrs ) {
+				attrs = this._getStoredAttributes();
+			}
+		}
+
+		return attrs;
+	}
+
+	/**
+	 * Fixes a selection range after it ends up in graveyard root.
+	 *
+	 * @private
+	 * @param {module:engine/model/liverange~LiveRange} liveRange The range from selection, that ended up in the graveyard root.
+	 * @param {module:engine/model/position~Position} removedRangeStart Start position of a range which was removed.
+	 */
+	_fixGraveyardSelection( liveRange, removedRangeStart ) {
+		// The start of the removed range is the closest position to the `liveRange` - the original selection range.
+		// This is a good candidate for a fixed selection range.
+		const positionCandidate = Position.createFromPosition( removedRangeStart );
+
+		// Find a range that is a correct selection range and is closest to the start of removed range.
+		const selectionRange = this._document.getNearestSelectionRange( positionCandidate );
+
+		// Remove the old selection range before preparing and adding new selection range. This order is important,
+		// because new range, in some cases, may intersect with old range (it depends on `getNearestSelectionRange()` result).
+		const index = this._ranges.indexOf( liveRange );
+		this._ranges.splice( index, 1 );
+		liveRange.detach();
+
+		// If nearest valid selection range has been found - add it in the place of old range.
+		if ( selectionRange ) {
+			// Check the range, convert it to live range, bind events, etc.
+			const newRange = this._prepareRange( selectionRange );
+
+			// Add new range in the place of old range.
+			this._ranges.splice( index, 0, newRange );
+		}
+		// If nearest valid selection range cannot be found - just removing the old range is fine.
+
+		// Fire an event informing about selection change.
+		this.fire( 'change:range', { directChange: false } );
+	}
+}
+
+/**
+ * @event change:attribute
+ */
+
+// Helper function for {@link module:engine/model/liveselection~LiveSelection#_updateAttributes}.
+//
+// It takes model item, checks whether it is a text node (or text proxy) and, if so, returns it's attributes. If not, returns `null`.
+//
+// @param {module:engine/model/item~Item|null}  node
+// @returns {Boolean}
+function getAttrsIfCharacter( node ) {
+	if ( node instanceof TextProxy || node instanceof Text ) {
+		return node.getAttributes();
+	}
+
+	return null;
+}
+
+// Removes selection attributes from element which is not empty anymore.
+function clearAttributesStoredInElement( changes, model, batch ) {
+	const changeParent = changes.range && changes.range.start.parent;
+
+	// `changes.range` is not set in case of rename, root and marker operations.
+	// None of them may lead to the element becoming non-empty.
+	if ( !changeParent || changeParent.isEmpty ) {
+		return;
+	}
+
+	model.enqueueChange( batch, writer => {
+		const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
+
+		for ( const key of storedAttributes ) {
+			writer.removeAttribute( key, changeParent );
+		}
+	} );
+}

+ 11 - 11
packages/ckeditor5-engine/src/model/selection.js

@@ -56,7 +56,7 @@ export default class Selection {
 		this._attrs = new Map();
 
 		if ( ranges ) {
-			this.setRanges( ranges, isLastBackward );
+			this._setRanges( ranges, isLastBackward );
 		}
 	}
 
@@ -306,19 +306,19 @@ export default class Selection {
 		if ( !selectable ) {
 			this.removeAllRanges();
 		} else if ( selectable instanceof Selection ) {
-			this.setRanges( selectable.getRanges(), selectable.isBackward );
+			this._setRanges( selectable.getRanges(), selectable.isBackward );
 		} else if ( selectable instanceof Range ) {
-			this.setRanges( [ selectable ] );
+			this._setRanges( [ selectable ] );
 		} else if ( isIterable( selectable ) ) {
 			// We assume that the selectable is an iterable of ranges.
-			this.setRanges( selectable, backwardSelectionOrOffset );
+			this._setRanges( selectable, backwardSelectionOrOffset );
 		} else if ( selectable instanceof Position ) {
 			// We assume that the selectable is a position.
-			this.setRanges( [ new Range( selectable ) ] );
+			this._setRanges( [ new Range( selectable ) ] );
 		} else if ( selectable instanceof Element ) {
-			this.setRanges( Position.createAt( selectable, backwardSelectionOrOffset ) );
+			this._setRanges( Position.createAt( selectable, backwardSelectionOrOffset ) );
 		} else {
-			throw new CKEditorError( 'model-selection-added-not-selectable' );
+			throw new CKEditorError( 'model-selection-set-not-selectable' );
 		}
 	}
 
@@ -333,7 +333,7 @@ export default class Selection {
 	 * @param {Boolean} [isLastBackward=false] Flag describing if last added range was selected forward - from start to end (`false`)
 	 * or backward - from end to start (`true`).
 	 */
-	setRanges( newRanges, isLastBackward = false ) {
+	_setRanges( newRanges, isLastBackward = false ) {
 		newRanges = Array.from( newRanges );
 
 		// Check whether there is any range in new ranges set that is different than all already added ranges.
@@ -437,7 +437,7 @@ export default class Selection {
 		const pos = Position.createAt( itemOrPosition, offset );
 		const range = new Range( pos, pos );
 
-		this.setRanges( [ range ] );
+		this._setRanges( [ range ] );
 	}
 
 	/**
@@ -451,7 +451,7 @@ export default class Selection {
 		const startPosition = this.getFirstPosition();
 
 		if ( startPosition !== null ) {
-			this.setRanges( [ new Range( startPosition, startPosition ) ] );
+			this._setRanges( [ new Range( startPosition, startPosition ) ] );
 		}
 	}
 
@@ -466,7 +466,7 @@ export default class Selection {
 		const endPosition = this.getLastPosition();
 
 		if ( endPosition !== null ) {
-			this.setRanges( [ new Range( endPosition, endPosition ) ] );
+			this._setRanges( [ new Range( endPosition, endPosition ) ] );
 		}
 	}
 

+ 11 - 22
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -205,13 +205,13 @@ describe( 'DocumentSelection', () => {
 		it( 'should convert added Range to LiveRange', () => {
 			selection._setTo( range );
 
-			expect( selection.getFirstRange() ).to.be.instanceof( LiveRange );
+			expect( selection._selection._ranges[ 0 ] ).to.be.instanceof( LiveRange );
 		} );
 
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
 				selection._setTo( { invalid: 'range' } );
-			} ).to.throw( CKEditorError, /model-selection-added-not-range/ );
+			} ).to.throw( CKEditorError, /model-selection-set-not-selectable/ );
 		} );
 
 		it( 'should not add a range that is in graveyard', () => {
@@ -219,12 +219,12 @@ describe( 'DocumentSelection', () => {
 
 			selection._setTo( Range.createIn( doc.graveyard ) );
 
-			expect( selection._ranges.length ).to.equal( 0 );
+			expect( selection._selection._ranges.length ).to.equal( 0 );
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
 		it( 'should refresh attributes', () => {
-			const spy = testUtils.sinon.spy( selection, '_updateAttributes' );
+			const spy = testUtils.sinon.spy( selection._selection, '_updateAttributes' );
 
 			selection._setTo( range );
 
@@ -239,7 +239,7 @@ describe( 'DocumentSelection', () => {
 			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
 			selection._setTo( root );
 
-			sinon.assert.calledTwice( spy.calledTwice ).to.be.true;
+			expect( spy.calledTwice ).to.be.true;
 		} );
 	} );
 
@@ -247,7 +247,7 @@ describe( 'DocumentSelection', () => {
 		it( 'should unbind all events', () => {
 			selection._setTo( [ range, liveRange ] );
 
-			const ranges = Array.from( selection.getRanges() );
+			const ranges = Array.from( selection._selection._ranges );
 
 			sinon.spy( ranges[ 0 ], 'detach' );
 			sinon.spy( ranges[ 1 ], 'detach' );
@@ -291,13 +291,12 @@ describe( 'DocumentSelection', () => {
 		let spy, ranges;
 
 		beforeEach( () => {
-			selection._selection.addRange( liveRange );
-			selection._selection.addRange( range );
+			selection._setTo( [ liveRange, range ] );
 
 			spy = sinon.spy();
 			selection.on( 'change:range', spy );
 
-			ranges = Array.from( selection.getRanges() );
+			ranges = Array.from( selection._selection._ranges );
 
 			sinon.spy( ranges[ 0 ], 'detach' );
 			sinon.spy( ranges[ 1 ], 'detach' );
@@ -322,7 +321,7 @@ describe( 'DocumentSelection', () => {
 		} );
 
 		it( 'should refresh attributes', () => {
-			const spy = sinon.spy( selection, '_updateAttributes' );
+			const spy = sinon.spy( selection._selection, '_updateAttributes' );
 
 			selection._removeAllRanges();
 
@@ -340,7 +339,7 @@ describe( 'DocumentSelection', () => {
 		it( 'should detach removed ranges', () => {
 			selection._setTo( [ liveRange, range ] );
 
-			const oldRanges = Array.from( selection.getRanges() );
+			const oldRanges = Array.from( selection._selection._ranges );
 
 			sinon.spy( oldRanges[ 0 ], 'detach' );
 			sinon.spy( oldRanges[ 1 ], 'detach' );
@@ -352,7 +351,7 @@ describe( 'DocumentSelection', () => {
 		} );
 
 		it( 'should refresh attributes', () => {
-			const spy = sinon.spy( selection, '_updateAttributes' );
+			const spy = sinon.spy( selection._selection, '_updateAttributes' );
 
 			selection._setTo( [ range ] );
 
@@ -393,16 +392,6 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'createFromSelection()', () => {
-		it( 'should throw', () => {
-			selection._setTo( range, true );
-
-			expect( () => {
-				DocumentSelection.createFromSelection( selection );
-			} ).to.throw( CKEditorError, /^documentselection-cannot-create:/ );
-		} );
-	} );
-
 	describe( '_isStoreAttributeKey', () => {
 		it( 'should return true if given key is a key of an attribute stored in element by DocumentSelection', () => {
 			expect( DocumentSelection._isStoreAttributeKey( fooStoreAttrKey ) ).to.be.true;

+ 31 - 31
packages/ckeditor5-engine/tests/model/selection.js

@@ -235,7 +235,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setIn()', () => {
+	describe.skip( 'setIn()', () => {
 		it( 'should set selection inside an element', () => {
 			const element = new Element( 'p', null, [ new Text( 'foo' ), new Text( 'bar' ) ] );
 
@@ -250,7 +250,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setOn()', () => {
+	describe.skip( 'setOn()', () => {
 		it( 'should set selection on an item', () => {
 			const textNode1 = new Text( 'foo' );
 			const textNode2 = new Text( 'bar' );
@@ -547,7 +547,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setRanges()', () => {
+	describe( '_setRanges()', () => {
 		let newRanges, spy;
 
 		beforeEach( () => {
@@ -565,31 +565,31 @@ describe( 'Selection', () => {
 
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
-				selection.setRanges( [ { invalid: 'range' } ] );
+				selection._setRanges( [ { invalid: 'range' } ] );
 			} ).to.throw( CKEditorError, /model-selection-added-not-range/ );
 		} );
 
 		it( 'should remove all ranges and add given ranges', () => {
-			selection.setRanges( newRanges );
+			selection._setRanges( newRanges );
 
 			const ranges = Array.from( selection.getRanges() );
 			expect( ranges ).to.deep.equal( newRanges );
 		} );
 
 		it( 'should use last range from given array to get anchor and focus position', () => {
-			selection.setRanges( newRanges );
+			selection._setRanges( newRanges );
 			expect( selection.anchor.path ).to.deep.equal( [ 5, 0 ] );
 			expect( selection.focus.path ).to.deep.equal( [ 6, 0 ] );
 		} );
 
 		it( 'should acknowledge backward flag when setting anchor and focus', () => {
-			selection.setRanges( newRanges, true );
+			selection._setRanges( newRanges, true );
 			expect( selection.anchor.path ).to.deep.equal( [ 6, 0 ] );
 			expect( selection.focus.path ).to.deep.equal( [ 5, 0 ] );
 		} );
 
 		it( 'should fire exactly one change:range event', () => {
-			selection.setRanges( newRanges );
+			selection._setRanges( newRanges );
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
@@ -598,18 +598,18 @@ describe( 'Selection', () => {
 				expect( data.directChange ).to.be.true;
 			} );
 
-			selection.setRanges( newRanges );
+			selection._setRanges( newRanges );
 		} );
 
 		it( 'should not fire change:range event if given ranges are the same', () => {
-			selection.setRanges( [ liveRange, range ] );
+			selection._setRanges( [ liveRange, range ] );
 			expect( spy.calledOnce ).to.be.false;
 		} );
 	} );
 
 	describe( 'setTo()', () => {
-		it( 'should set selection to be same as given selection, using setRanges method', () => {
-			const spy = sinon.spy( selection, 'setRanges' );
+		it( 'should set selection to be same as given selection, using _setRanges method', () => {
+			const spy = sinon.spy( selection, '_setRanges' );
 
 			const otherSelection = new Selection();
 			otherSelection.addRange( range1 );
@@ -619,34 +619,34 @@ describe( 'Selection', () => {
 
 			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1, range2 ] );
 			expect( selection.isBackward ).to.be.true;
-			expect( selection.setRanges.calledOnce ).to.be.true;
+			expect( selection._setRanges.calledOnce ).to.be.true;
 			spy.restore();
 		} );
 
-		it( 'should set selection on the given Range using setRanges method', () => {
-			const spy = sinon.spy( selection, 'setRanges' );
+		it( 'should set selection on the given Range using _setRanges method', () => {
+			const spy = sinon.spy( selection, '_setRanges' );
 
 			selection.setTo( range1 );
 
 			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1 ] );
 			expect( selection.isBackward ).to.be.false;
-			expect( selection.setRanges.calledOnce ).to.be.true;
+			expect( selection._setRanges.calledOnce ).to.be.true;
 			spy.restore();
 		} );
 
-		it( 'should set selection on the given iterable of Ranges using setRanges method', () => {
-			const spy = sinon.spy( selection, 'setRanges' );
+		it( 'should set selection on the given iterable of Ranges using _setRanges method', () => {
+			const spy = sinon.spy( selection, '_setRanges' );
 
 			selection.setTo( new Set( [ range1, range2 ] ) );
 
 			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1, range2 ] );
 			expect( selection.isBackward ).to.be.false;
-			expect( selection.setRanges.calledOnce ).to.be.true;
+			expect( selection._setRanges.calledOnce ).to.be.true;
 			spy.restore();
 		} );
 
-		it( 'should set collapsed selection on the given Position using setRanges method', () => {
-			const spy = sinon.spy( selection, 'setRanges' );
+		it( 'should set collapsed selection on the given Position using _setRanges method', () => {
+			const spy = sinon.spy( selection, '_setRanges' );
 			const position = new Position( root, [ 4 ] );
 
 			selection.setTo( position );
@@ -655,7 +655,7 @@ describe( 'Selection', () => {
 			expect( Array.from( selection.getRanges() )[ 0 ].start ).to.deep.equal( position );
 			expect( selection.isBackward ).to.be.false;
 			expect( selection.isCollapsed ).to.be.true;
-			expect( selection.setRanges.calledOnce ).to.be.true;
+			expect( selection._setRanges.calledOnce ).to.be.true;
 			spy.restore();
 		} );
 	} );
@@ -796,7 +796,7 @@ describe( 'Selection', () => {
 
 	describe( 'collapseToStart()', () => {
 		it( 'should collapse to start position and fire change event', () => {
-			selection.setRanges( [ range2, range1, range3 ] );
+			selection._setRanges( [ range2, range1, range3 ] );
 
 			const spy = sinon.spy();
 			selection.on( 'change:range', spy );
@@ -832,7 +832,7 @@ describe( 'Selection', () => {
 
 	describe( 'collapseToEnd()', () => {
 		it( 'should collapse to start position and fire change:range event', () => {
-			selection.setRanges( [ range2, range3, range1 ] );
+			selection._setRanges( [ range2, range3, range1 ] );
 
 			const spy = sinon.spy();
 			selection.on( 'change:range', spy );
@@ -1132,7 +1132,7 @@ describe( 'Selection', () => {
 
 		describe( 'setAttribute()', () => {
 			it( 'should set given attribute on the selection', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
 				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
@@ -1167,7 +1167,7 @@ describe( 'Selection', () => {
 
 		describe( 'getAttributes()', () => {
 			it( 'should return an iterator that iterates over all attributes set on selection', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.setAttribute( 'abc', 'xyz' );
 
@@ -1179,7 +1179,7 @@ describe( 'Selection', () => {
 
 		describe( 'getAttributeKeys()', () => {
 			it( 'should return iterator that iterates over all attribute keys set on selection', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.setAttribute( 'abc', 'xyz' );
 
@@ -1191,7 +1191,7 @@ describe( 'Selection', () => {
 
 		describe( 'hasAttribute()', () => {
 			it( 'should return true if element contains attribute with given key', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
 				expect( selection.hasAttribute( 'foo' ) ).to.be.true;
@@ -1204,7 +1204,7 @@ describe( 'Selection', () => {
 
 		describe( 'clearAttributes()', () => {
 			it( 'should remove all attributes from the element', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.setAttribute( 'abc', 'xyz' );
 
@@ -1237,7 +1237,7 @@ describe( 'Selection', () => {
 
 		describe( 'removeAttribute()', () => {
 			it( 'should remove attribute', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttribute( 'foo', 'bar' );
 				selection.removeAttribute( 'foo' );
 
@@ -1297,7 +1297,7 @@ describe( 'Selection', () => {
 			} );
 
 			it( 'should not fire change:attribute event if attributes had not changed', () => {
-				selection.setRanges( [ rangeInFullP ] );
+				selection._setRanges( [ rangeInFullP ] );
 				selection.setAttributesTo( { foo: 'bar', xxx: 'yyy' } );
 
 				const spy = sinon.spy();