Sfoglia il codice sorgente

Changed: multiple changes in LiveSelection.

Change log:
moved callbacks updating LiveSelection attributes to LiveSelection,
fixed events to fire only when range or attribute changed,
removed enqueueChanges blocks,
fixed attribute overwriting by _updateAttributes,
other minor fixes.
Szymon Cofalik 9 anni fa
parent
commit
6145600e7c
1 ha cambiato i file con 274 aggiunte e 67 eliminazioni
  1. 274 67
      packages/ckeditor5-engine/src/model/liveselection.js

+ 274 - 67
packages/ckeditor5-engine/src/model/liveselection.js

@@ -3,10 +3,12 @@
  * For licensing, see LICENSE.md.
  */
 
+import Range from './range.js';
 import LiveRange from './liverange.js';
 import Text from './text.js';
 import TextProxy from './textproxy.js';
 import toMap from '../../utils/tomap.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
 
 import Selection from './selection.js';
 
@@ -40,10 +42,41 @@ export default class LiveSelection extends Selection {
 		/**
 		 * Document which owns this selection.
 		 *
-		 * @private
+		 * @protected
 		 * @member {engine.model.Document} engine.model.LiveSelection#_document
 		 */
 		this._document = document;
+
+		/**
+		 * 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} engine.model.LiveSelection#_attributePriority
+		 */
+		this._attributePriority = new Map();
+
+		// Whenever selection range changes, if the change comes directly from selection API (direct user change).
+		this.on( 'change:range', ( evt, data ) => {
+			if ( data.directChange ) {
+				// Reset attributes on selection (clear attributes and priorities).
+				//this._resetAttributes();
+				// And get attributes from surrounding nodes.
+				this._updateAttributes( true );
+			}
+		}, { priority: 'high' } );
+
+		// Whenever attribute operation is performed on document, update attributes. This is not the most efficient
+		// way to update selection attributes, but should be okay for now. `_updateAttributes` will be fired too often,
+		// but it won't change attributes or fire `change:attribute` event if not needed.
+		this.listenTo( this._document, 'change', ( evt, type ) => {
+			if ( attrOpTypes.has( type ) ) {
+				this._updateAttributes( false );
+			}
+		} );
 	}
 
 	/**
@@ -83,6 +116,8 @@ export default class LiveSelection extends Selection {
 		for ( let i = 0; i < this._ranges.length; i++ ) {
 			this._ranges[ i ].detach();
 		}
+
+		this.stopListening();
 	}
 
 	/**
@@ -113,49 +148,79 @@ export default class LiveSelection extends Selection {
 	/**
 	 * @inheritDoc
 	 */
-	removeAllRanges() {
-		this.destroy();
-		super.removeAllRanges();
-	}
+	setAttribute( key, value ) {
+		// Store attribute in parent element if the selection is collapsed in an empty node.
+		if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
+			this._storeAttribute( key, value );
+		}
 
-	/**
-	 * @inheritDoc
-	 */
-	setRanges( newRanges, isLastBackward ) {
-		this.destroy();
-		super.setRanges( newRanges, isLastBackward );
+		// This uses private internal `_setAttribute` method which uses `directChange`.
+		// It's done like that to not document `directChange` in documentation because it's internal `LiveSelection` stuff.
+		if ( this._setAttribute( key, value ) ) {
+			// Fire event with exact data.
+			const attributeKeys = [ key ];
+			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
+		}
 	}
 
 	/**
 	 * @inheritDoc
 	 */
-	clearAttributes() {
-		this._setStoredAttributesTo( new Map() );
-		super.clearAttributes();
+	removeAttribute( key ) {
+		// Remove stored attribute from parent element if the selection is collapsed in an empty node.
+		if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
+			this._removeStoredAttribute( key );
+		}
+
+		// This uses private internal `_removeAttribute` method which uses `directChange`.
+		// It's done like that to not document `directChange` in documentation because it's internal `LiveSelection` stuff.
+		if ( this._removeAttribute( key ) ) {
+			// Fire event with exact data.
+			const attributeKeys = [ key ];
+			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
+		}
 	}
 
 	/**
 	 * @inheritDoc
 	 */
-	removeAttribute( key ) {
-		this._removeStoredAttribute( key );
-		super.removeAttribute( key );
+	setAttributesTo( attrs ) {
+		attrs = toMap( attrs );
+
+		if ( this.isCollapsed && this.anchor.parent.childCount === 0 ) {
+			this._setStoredAttributesTo( attrs );
+		}
+
+		// This uses private internal `_setAttributesTo` method which uses `directChange`.
+		// It's done like that to not document `directChange` in documentation because it's internal `LiveSelection` stuff.
+		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
 	 */
-	setAttribute( key, value ) {
-		this._storeAttribute( key, value );
-		super.setAttribute( key, value );
+	clearAttributes() {
+		this.setAttributesTo( [] );
 	}
 
 	/**
-	 * @inheritDoc
+	 * Creates and returns an instance of `LiveSelection` that is a clone of given selection, meaning that it has same
+	 * ranges and same direction as this selection.
+	 *
+	 * @params {engine.model.Selection} otherSelection Selection to be cloned.
+	 * @returns {engine.model.LiveSelection} `Selection` instance that is a clone of given selection.
 	 */
-	setAttributesTo( attrs ) {
-		this._setStoredAttributesTo( toMap( attrs ) );
-		super.setAttributesTo( attrs );
+	static createFromSelection( otherSelection ) {
+		const selection = new this( otherSelection._document );
+		selection.setTo( otherSelection );
+
+		return selection;
 	}
 
 	/**
@@ -169,12 +234,121 @@ export default class LiveSelection extends Selection {
 	 * @inheritDoc
 	 */
 	_pushRange( range ) {
+		if ( !( range instanceof Range ) ) {
+			throw new CKEditorError( 'model-selection-added-not-range: Trying to add an object that is not an instance of Range.' );
+		}
+
 		this._checkRange( range );
-		this._ranges.push( LiveRange.createFromRange( range ) );
+
+		const liveRange = LiveRange.createFromRange( range );
+		this.listenTo( liveRange, 'change', () => {
+			this.fire( 'change:range', { directChange: false } );
+		} );
+
+		this._ranges.push( liveRange );
 	}
 
 	/**
-	 * Iterates through all attributes stored in current selection's parent.
+	 * Internal method for setting `LiveSelection` attribute. Supports attributes 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.
+	 */
+	_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 attributes 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.
+	 */
+	_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 attributes 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.
+	 */
+	_setAttributesTo( attrs, directChange = true ) {
+		const changed = new Set();
+
+		for ( let [ 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 ( let [ 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.
 	 *
 	 * @returns {Iterable.<*>}
 	 */
@@ -199,72 +373,56 @@ export default class LiveSelection extends Selection {
 	 * @param {String} key Key of attribute to remove.
 	 */
 	_removeStoredAttribute( key ) {
-		const selectionParent = this.getFirstPosition().parent;
+		const storeKey = LiveSelection._getStoreAttributeKey( key );
 
-		if ( this.isCollapsed && selectionParent.childCount === 0 ) {
-			const storeKey = LiveSelection._getStoreAttributeKey( key );
-
-			this._document.enqueueChanges( () => {
-				this._document.batch().removeAttribute( selectionParent, storeKey );
-			} );
-		}
+		this._document.batch().removeAttribute( this.anchor.parent, storeKey );
 	}
 
 	/**
-	 * Stores given attribute key and value in current selection's parent node if the selection is collapsed and
-	 * the parent node is empty.
+	 * 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 selectionParent = this.getFirstPosition().parent;
+		const storeKey = LiveSelection._getStoreAttributeKey( key );
 
-		if ( this.isCollapsed && selectionParent.childCount === 0 ) {
-			const storeKey = LiveSelection._getStoreAttributeKey( key );
-
-			this._document.enqueueChanges( () => {
-				this._document.batch().setAttribute( selectionParent, storeKey, value );
-			} );
-		}
+		this._document.batch().setAttribute( this.anchor.parent, storeKey, value );
 	}
 
 	/**
 	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
 	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
 	 * @private
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
 	 */
 	_setStoredAttributesTo( attrs ) {
-		const selectionParent = this.getFirstPosition().parent;
-
-		if ( this.isCollapsed && selectionParent.childCount === 0 ) {
-			this._document.enqueueChanges( () => {
-				const batch = this._document.batch();
+		const selectionParent = this.anchor.parent;
+		const batch = this._document.batch();
 
-				for ( let attr of this._getStoredAttributes() ) {
-					const storeKey = LiveSelection._getStoreAttributeKey( attr[ 0 ] );
+		for ( let [ oldKey ] of this._getStoredAttributes() ) {
+			const storeKey = LiveSelection._getStoreAttributeKey( oldKey );
 
-					batch.removeAttribute( selectionParent, storeKey );
-				}
+			batch.removeAttribute( selectionParent, storeKey );
+		}
 
-				for ( let attr of attrs ) {
-					const storeKey = LiveSelection._getStoreAttributeKey( attr[ 0 ] );
+		for ( let [ key, value ] of attrs ) {
+			const storeKey = LiveSelection._getStoreAttributeKey( key );
 
-					batch.setAttribute( selectionParent, storeKey, attr[ 1 ] );
-				}
-			} );
+			batch.setAttribute( selectionParent, storeKey, value );
 		}
 	}
 
 	/**
-	 * Updates this selection attributes according to it's ranges and the document.
+	 * 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.
 	 *
-	 * @fires engine.model.LiveSelection#change:attribute
-	 * @protected
+	 * @private
+	 * @returns {Iterable.<*>} Collection of attributes.
 	 */
-	_updateAttributes() {
+	_getSurroundingAttributes() {
 		const position = this.getFirstPosition();
 
 		let attrs = null;
@@ -321,13 +479,58 @@ export default class LiveSelection extends Selection {
 			}
 		}
 
-		if ( attrs ) {
-			this._attrs = new Map( attrs );
+		return attrs;
+	}
+
+	/**
+	 * Updates this selection attributes according to it's ranges and the {@link engine.model.Document model document}.
+	 *
+	 * @fires engine.model.LiveSelection#change:attribute
+	 * @param {Boolean} clear
+	 * @protected
+	 */
+	_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 {
-			this.clearAttributes();
+			// If not, remove only attributes added with `low` priority.
+			for ( let [ key, priority ] of this._attributePriority ) {
+				if ( priority == 'low' ) {
+					this._attrs.delete( key );
+					this._attributePriority.delete( key );
+				}
+			}
 		}
 
-		this.fire( 'change:attribute' );
+		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 ( let [ 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 ( let [ 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 } );
+		}
 	}
 
 	/**
@@ -353,3 +556,7 @@ function _getAttrsIfCharacter( node ) {
 
 	return null;
 }
+
+const attrOpTypes = new Set(
+	[ 'addAttribute', 'removeAttribute', 'changeAttribute', 'addRootAttribute', 'removeRootAttribute', 'changeRootAttribute' ]
+);