瀏覽代碼

Changed: Moved model.Selection#_getDefaultRange to model.DocumentSelection.

Szymon Cofalik 9 年之前
父節點
當前提交
04bdbeb3a2

+ 89 - 3
packages/ckeditor5-engine/src/model/documentselection.js

@@ -6,6 +6,8 @@
 'use strict';
 
 import LiveRange from './liverange.js';
+import Range from './range.js';
+import Position from './position.js';
 import CharacterProxy from './characterproxy.js';
 import toMap from '../../utils/tomap.js';
 
@@ -18,7 +20,9 @@ const storePrefix = 'selection:';
  * that user interacts with. `DocumentSelection` instance is created by {@link engine.model.Document}. You should not
  * create an instance of `DocumentSelection`.
  *
- * Differences between {@link engine.model.Selection} and `DocumentSelection` are two:
+ * Differences between {@link engine.model.Selection} and `DocumentSelection` are three:
+ * * there is always a range in `DocumentSelection`, even if no ranges were added - in this case, there is a
+ * "default range" in selection which is a collapsed range set at the beginning of the {@link engine.model.Document document},
  * * ranges added to this selection updates automatically when the document changes,
  * * document selection may have attributes.
  *
@@ -26,10 +30,20 @@ const storePrefix = 'selection:';
  */
 export default class DocumentSelection extends Selection {
 	/**
-	 * @inheritDoc
+	 * Creates an empty document selection for given {@link engine.model.Document}.
+	 *
+	 * @param {engine.model.Document} document Document which owns this selection.
 	 */
 	constructor( document ) {
-		super( document );
+		super();
+
+		/**
+		 * Document which owns this selection.
+		 *
+		 * @private
+		 * @member {engine.model.Document} engine.model.Selection#_document
+		 */
+		this._document = document;
 
 		/**
 		 * List of attributes set on current selection.
@@ -41,6 +55,36 @@ export default class DocumentSelection extends Selection {
 	}
 
 	/**
+	 * @inheritDoc
+	 */
+	get isCollapsed() {
+		const length = this._ranges.length;
+
+		return length === 0 ? true : super.isCollapsed;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get anchor() {
+		return super.anchor || this._getDefaultRange().start;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get focus() {
+		return super.focus || this._getDefaultRange().start;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get rangeCount() {
+		return this._ranges.length ? this._ranges.length : 1;
+	}
+
+	/**
 	 * Unbinds all events previously bound by document selection.
 	 */
 	destroy() {
@@ -52,6 +96,24 @@ export default class DocumentSelection extends Selection {
 	/**
 	 * @inheritDoc
 	 */
+	*getRanges() {
+		if ( this._ranges.length ) {
+			yield *super.getRanges();
+		} else {
+			yield this._getDefaultRange();
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	getFirstRange() {
+		return super.getFirstRange() || this._getDefaultRange();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
 	removeAllRanges() {
 		this.destroy();
 		super.removeAllRanges();
@@ -162,6 +224,30 @@ export default class DocumentSelection extends Selection {
 	}
 
 	/**
+	 * Returns a default range for this selection. The default range is a collapsed range that starts and ends
+	 * at the beginning of this selection's document {@link engine.model.Document#_getDefaultRoot default root}.
+	 * This "artificial" range is important for algorithms that base on selection, so they won't break or need
+	 * special logic if there are no real ranges in the selection.
+	 *
+	 * @private
+	 * @returns {engine.model.Range}
+	 */
+	_getDefaultRange() {
+		const defaultRoot = this._document._getDefaultRoot();
+
+		// Find the first position where the selection can be put.
+		for ( let position of Range.createFromElement( defaultRoot ).getPositions() ) {
+			if ( this._document.schema.check( { name: '$text', inside: position } ) ) {
+				return new Range( position, position );
+			}
+		}
+
+		const position = new Position( defaultRoot, [ 0 ] );
+
+		return new Range( position, position );
+	}
+
+	/**
 	 * Iterates through all attributes stored in current selection's parent.
 	 *
 	 * @returns {Iterable.<*>}

+ 43 - 57
packages/ckeditor5-engine/src/model/selection.js

@@ -20,18 +20,8 @@ import mix from '../../utils/mix.js';
 export default class Selection {
 	/**
 	 * Creates an empty selection.
-	 *
-	 * @param {engine.model.Document} document Document which owns this selection.
 	 */
-	constructor( document ) {
-		/**
-		 * Document which owns this selection.
-		 *
-		 * @private
-		 * @member {engine.model.Document} engine.model.Selection#_document
-		 */
-		this._document = document;
-
+	constructor() {
 		/**
 		 * Specifies whether the last added range was added as a backward or forward range.
 		 *
@@ -54,25 +44,37 @@ export default class Selection {
 	 * {@link engine.model.Selection#focus} they define the direction of selection, which is important
 	 * when expanding/shrinking selection. Anchor is always the start or end of the most recent added range.
 	 *
+	 * Is set to `null` if there are no ranges in selection.
+	 *
 	 * @see engine.model.Selection#focus
-	 * @type {engine.model.Position}
+	 * @type {engine.model.Position|null}
 	 */
 	get anchor() {
-		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
+		if ( this._ranges.length > 0 ) {
+			const range = this._ranges[ this._ranges.length - 1 ];
 
-		return this._lastRangeBackward ? range.end : range.start;
+			return this._lastRangeBackward ? range.end : range.start;
+		}
+
+		return null;
 	}
 
 	/**
 	 * Selection focus. Focus is a position where the selection ends.
 	 *
+	 * Is set to `null` if there are no ranges in selection.
+	 *
 	 * @see engine.model.Selection#anchor
-	 * @type {engine.model.Position}
+	 * @type {engine.model.Position|null}
 	 */
 	get focus() {
-		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
+		if ( this._ranges.length > 0 ) {
+			const range = this._ranges[ this._ranges.length - 1 ];
+
+			return this._lastRangeBackward ? range.start : range.end;
+		}
 
-		return this._lastRangeBackward ? range.start : range.end;
+		return null;
 	}
 
 	/**
@@ -84,10 +86,7 @@ export default class Selection {
 	get isCollapsed() {
 		const length = this._ranges.length;
 
-		if ( length === 0 ) {
-			// Default range is collapsed.
-			return true;
-		} else if ( length === 1 ) {
+		if ( length === 1 ) {
 			return this._ranges[ 0 ].isCollapsed;
 		} else {
 			return false;
@@ -100,7 +99,7 @@ export default class Selection {
 	 * @type {Number}
      */
 	get rangeCount() {
-		return this._ranges.length ? this._ranges.length : 1;
+		return this._ranges.length;
 	}
 
 	/**
@@ -139,21 +138,19 @@ export default class Selection {
 	 * @returns {Iterator.<engine.model.Range>}
 	 */
 	*getRanges() {
-		if ( this._ranges.length ) {
-			for ( let range of this._ranges ) {
-				yield Range.createFromRange( range );
-			}
-		} else {
-			yield this._getDefaultRange();
+		for ( let range of this._ranges ) {
+			yield Range.createFromRange( range );
 		}
 	}
 
 	/**
-	 * Returns the first range in the selection. First range is the one which {@link engine.model.Range#start start} position
+	 * Returns a copy of the first range in the selection. First range is the one which {@link engine.model.Range#start start} position
 	 * {@link engine.model.Position#isBefore is before} start position of all other ranges (not to confuse with the first range
 	 * added to the selection).
 	 *
-	 * @returns {engine.model.Range}
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Range|null}
 	 */
 	getFirstRange() {
 		let first = null;
@@ -166,17 +163,21 @@ export default class Selection {
 			}
 		}
 
-		return first ? Range.createFromRange( first ) : this._getDefaultRange();
+		return first ? Range.createFromRange( first ) : null;
 	}
 
 	/**
 	 * Returns the first position in the selection. First position is the position that {@link engine.model.Position#isBefore is before}
 	 * any other position in the selection ranges.
 	 *
-	 * @returns {engine.model.Position}
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Position|null}
 	 */
 	getFirstPosition() {
-		return Position.createFromPosition( this.getFirstRange().start );
+		const first = this.getFirstRange();
+
+		return first ? Position.createFromPosition( first.start ) : null;
 	}
 
 	/**
@@ -240,6 +241,15 @@ export default class Selection {
 	 * first parameter is a node.
 	 */
 	setFocus( nodeOrPosition, offset ) {
+		if ( this._ranges.length === 0 ) {
+			/**
+			 * Cannot set selection focus if there are no ranges in selection.
+			 *
+			 * @error selection-setFocus-no-ranges
+			 */
+			throw new CKEditorError( 'selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.' );
+		}
+
 		const newFocus = Position.createAt( nodeOrPosition, offset );
 
 		if ( newFocus.compareWith( this.focus ) == 'SAME' ) {
@@ -304,30 +314,6 @@ export default class Selection {
 		this._checkRange( range );
 		this._ranges.push( Range.createFromRange( range ) );
 	}
-
-	/**
-	 * Returns a default range for this selection. The default range is a collapsed range that starts and ends
-	 * at the beginning of this selection's document {@link engine.model.Document#_getDefaultRoot default root}.
-	 * This "artificial" range is important for algorithms that base on selection, so they won't break or need
-	 * special logic if there are no real ranges in the selection.
-	 *
-	 * @private
-	 * @returns {engine.model.Range}
-	 */
-	_getDefaultRange() {
-		const defaultRoot = this._document._getDefaultRoot();
-
-		// Find the first position where the selection can be put.
-		for ( let position of Range.createFromElement( defaultRoot ).getPositions() ) {
-			if ( this._document.schema.check( { name: '$text', inside: position } ) ) {
-				return new Range( position, position );
-			}
-		}
-
-		const position = new Position( defaultRoot, [ 0 ] );
-
-		return new Range( position, position );
-	}
 }
 
 mix( Selection, EmitterMixin );

+ 101 - 0
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -53,6 +53,74 @@ describe( 'DocumentSelection', () => {
 		liveRange.detach();
 	} );
 
+	describe( 'default range', () => {
+		it( 'should go to the first editable element', () => {
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection ).to.have.property( 'isBackward', false );
+		} );
+
+		it( 'should be set to the beginning of the doc if there is no editable element', () => {
+			doc = new Document();
+			root = doc.createRoot();
+			root.insertChildren( 0, 'foobar' );
+			selection = doc.selection;
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
+			expect( selection ).to.have.property( 'isBackward', false );
+			expect( selection._attrs ).to.be.instanceof( Map );
+			expect( selection._attrs.size ).to.equal( 0 );
+		} );
+
+		it( 'should skip element when you can not put selection', () => {
+			doc = new Document();
+			root = doc.createRoot();
+			root.insertChildren( 0, [
+				new Element( 'img' ),
+				new Element( 'p', [], 'foobar' )
+			] );
+			doc.schema.registerItem( 'img' );
+			doc.schema.registerItem( 'p', '$block' );
+			selection = doc.selection;
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
+			expect( selection ).to.have.property( 'isBackward', false );
+			expect( selection._attrs ).to.be.instanceof( Map );
+			expect( selection._attrs.size ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'isCollapsed', () => {
+		it( 'should return true for default range', () => {
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+	} );
+
+	describe( 'rangeCount', () => {
+		it( 'should return proper range count', () => {
+			expect( selection.rangeCount ).to.equal( 1 );
+
+			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+
+			expect( selection.rangeCount ).to.equal( 1 );
+
+			selection.addRange( new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) );
+
+			expect( selection.rangeCount ).to.equal( 2 );
+		} );
+	} );
+
 	describe( 'addRange', () => {
 		it( 'should convert added Range to LiveRange', () => {
 			selection.addRange( range );
@@ -96,6 +164,16 @@ describe( 'DocumentSelection', () => {
 	} );
 
 	describe( 'setFocus', () => {
+		it( 'modifies default range', () => {
+			const startPos = selection.getFirstPosition();
+			const endPos = Position.createAt( root, 'END' );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+		} );
+
 		it( 'detaches the range it replaces', () => {
 			const startPos = Position.createAt( root, 1 );
 			const endPos = Position.createAt( root, 2 );
@@ -133,6 +211,12 @@ describe( 'DocumentSelection', () => {
 			ranges[ 1 ].detach.restore();
 		} );
 
+		it( 'should remove all stored ranges (and reset to default range)', () => {
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		} );
+
 		it( 'should detach ranges', () => {
 			expect( ranges[ 0 ].detach.called ).to.be.true;
 			expect( ranges[ 1 ].detach.called ).to.be.true;
@@ -174,6 +258,23 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	describe( 'getFirstRange', () => {
+		it( 'should return default range if no ranges were added', () => {
+			const firstRange = selection.getFirstRange();
+
+			expect( firstRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( firstRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
+	describe( 'getFirstPosition', () => {
+		it( 'should return start position of default range if no ranges were added', () => {
+			const firstPosition = selection.getFirstPosition();
+
+			expect( firstPosition.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
 	// Selection uses LiveRanges so here are only simple test to see if integration is
 	// working well, without getting into complicated corner cases.
 	describe( 'after applying an operation should get updated and not fire update event', () => {

+ 18 - 63
packages/ckeditor5-engine/tests/model/selection.js

@@ -34,8 +34,7 @@ describe( 'Selection', () => {
 			new Element( 'p' ),
 			new Element( 'p', [], 'foobar' )
 		] );
-		selection = new Selection( doc );
-		doc.schema.registerItem( 'p', '$block' );
+		selection = new Selection();
 
 		liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
 		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 2 ] ) );
@@ -46,57 +45,9 @@ describe( 'Selection', () => {
 		liveRange.detach();
 	} );
 
-	describe( 'default range', () => {
-		it( 'should go to the first editable element', () => {
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
-			expect( selection ).to.have.property( 'isBackward', false );
-		} );
-
-		it( 'should be set to the beginning of the doc if there is no editable element', () => {
-			doc = new Document();
-			root = doc.createRoot();
-			root.insertChildren( 0, 'foobar' );
-			selection = doc.selection;
-
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
-			expect( selection ).to.have.property( 'isBackward', false );
-			expect( selection._attrs ).to.be.instanceof( Map );
-			expect( selection._attrs.size ).to.equal( 0 );
-		} );
-
-		it( 'should skip element when you can not put selection', () => {
-			doc = new Document();
-			root = doc.createRoot();
-			root.insertChildren( 0, [
-				new Element( 'img' ),
-				new Element( 'p', [], 'foobar' )
-			] );
-			doc.schema.registerItem( 'img' );
-			doc.schema.registerItem( 'p', '$block' );
-			selection = doc.selection;
-
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
-			expect( selection ).to.have.property( 'isBackward', false );
-			expect( selection._attrs ).to.be.instanceof( Map );
-			expect( selection._attrs.size ).to.equal( 0 );
-		} );
-	} );
-
 	describe( 'isCollapsed', () => {
-		it( 'should return true for default range', () => {
-			expect( selection.isCollapsed ).to.be.true;
+		it( 'should return false for empty selection', () => {
+			expect( selection.isCollapsed ).to.be.false;
 		} );
 
 		it( 'should return true when there is single collapsed ranges', () => {
@@ -121,7 +72,7 @@ describe( 'Selection', () => {
 
 	describe( 'rangeCount', () => {
 		it( 'should return proper range count', () => {
-			expect( selection.rangeCount ).to.equal( 1 );
+			expect( selection.rangeCount ).to.equal( 0 );
 
 			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
 
@@ -322,14 +273,12 @@ describe( 'Selection', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
-		it( 'modifies default range', () => {
-			const startPos = selection.getFirstPosition();
+		it( 'throws if there are no ranges in selection', () => {
 			const endPos = Position.createAt( root, 'END' );
 
-			selection.setFocus( endPos );
-
-			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
-			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+			expect( () => {
+				selection.setFocus( endPos );
+			} ).to.throw( CKEditorError, /selection-setFocus-no-ranges/ );
 		} );
 
 		it( 'modifies existing collapsed selection', () => {
@@ -485,10 +434,8 @@ describe( 'Selection', () => {
 			selection.removeAllRanges();
 		} );
 
-		it( 'should remove all stored ranges (and reset to default range)', () => {
-			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		it( 'should remove all stored ranges', () => {
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 0 );
 		} );
 
 		it( 'should fire exactly one update event', () => {
@@ -545,6 +492,10 @@ describe( 'Selection', () => {
 	} );
 
 	describe( 'getFirstRange', () => {
+		it( 'should return null if no ranges were added', () => {
+			expect( selection.getFirstRange() ).to.be.null;
+		} );
+
 		it( 'should return a range which start position is before all other ranges\' start positions', () => {
 			// This will not be the first range despite being added as first
 			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );
@@ -563,6 +514,10 @@ describe( 'Selection', () => {
 	} );
 
 	describe( 'getFirstPosition', () => {
+		it( 'should return null if no ranges were added', () => {
+			expect( selection.getFirstPosition() ).to.be.null;
+		} );
+
 		it( 'should return a position that is in selection and is before any other position from the selection', () => {
 			// This will not be a range containing the first position despite being added as first
 			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );