Browse Source

Added: engine.model.Range#getTransformedByDelta, #_getTransformedByDocumentChange, .createFromRanges.

Szymon Cofalik 9 năm trước cách đây
mục cha
commit
f5c8c70cc6

+ 120 - 0
packages/ckeditor5-engine/src/model/range.js

@@ -9,6 +9,7 @@
 
 
 import Position from './position.js';
 import Position from './position.js';
 import TreeWalker from './treewalker.js';
 import TreeWalker from './treewalker.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
 
 
 /**
 /**
  * Range class. Range is iterable.
  * Range class. Range is iterable.
@@ -368,6 +369,56 @@ export default class Range {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Returns a range that is a result of transforming this range by given `delta`.
+	 *
+	 * @param {engine.model.Delta} delta Delta to transform range by.
+	 * @returns {Array.<engine.model.Range>} Range which is the result of transformation.
+	 */
+	getTransformedByDelta( delta ) {
+		let ranges = [ Range.createFromRange( this ) ];
+
+		// Operation types that a range can be transformed by.
+		const supportedTypes = new Set( [ 'insert', 'move', 'remove', 'reinsert' ] );
+
+		for ( let operation of delta.operations ) {
+			if ( supportedTypes.has( operation.type ) ) {
+				for ( let i = 0; i < ranges.length; i++ ) {
+					const result = ranges[ i ]._getTransformedByDocumentChange(
+						operation.type,
+						operation.targetPosition || operation.position,
+						operation.howMany || operation.nodes.maxOffset,
+						operation.sourcePosition
+					);
+
+					ranges.splice( i, 1, ...result );
+
+					i += result.length - 1;
+				}
+			}
+		}
+
+		return ranges;
+	}
+
+	/**
+	 * Returns a range that is a result of transforming this range by a change in the model document.
+	 *
+	 * @protected
+	 * @param {'insert'|'move'|'remove'|'reinsert'} type Change type.
+	 * @param {engine.model.Position} targetPosition Position before the first changed node.
+	 * @param {Number} howMany How many nodes has been changed.
+	 * @param {engine.model.Position} sourcePosition Source position of changes.
+	 * @returns {Array.<engine.model.Range>}
+	 */
+	_getTransformedByDocumentChange( type, targetPosition, howMany, sourcePosition ) {
+		if ( type == 'insert' ) {
+			return this._getTransformedByInsertion( targetPosition, howMany, false, false );
+		} else {
+			return this._getTransformedByMove( sourcePosition, targetPosition, howMany );
+		}
+	}
+
 	/**
 	/**
 	 * Returns an array containing one or two {@link ~Range ranges} that are a result of transforming this
 	 * Returns an array containing one or two {@link ~Range ranges} that are a result of transforming this
 	 * {@link ~Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link ~Range ranges} are
 	 * {@link ~Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link ~Range ranges} are
@@ -556,6 +607,75 @@ export default class Range {
 		return this.createFromPositionAndShift( Position.createBefore( item ), item.offsetSize );
 		return this.createFromPositionAndShift( Position.createBefore( item ), item.offsetSize );
 	}
 	}
 
 
+	/**
+	 * Combines all ranges from the passed array into a one range. At least one range has to be passed.
+	 * Passed ranges must not have common parts.
+	 *
+	 * The first range from the array is a reference range. If other ranges {@link engine.model.Position.isTouching are touching}
+	 * the reference range, they will get combined into one range.
+	 *
+	 *		[  ][]  [    ][ ][  ref range  ][ ][]  [  ]  // Passed ranges, shown sorted. "Ref range" was the first range in original array.
+	 *		        [      returned range       ]  [  ]  // The combined range.
+	 *		[    ]                                       // The result of the function if the first range was a reference range.
+	 *	            [                           ]        // The result of the function if the third-to-seventh range was a reference range.
+	 *	                                           [  ]  // The result of the function if the last range was a reference range.
+	 *
+	 * @param {Array.<engine.model.Range>} ranges Ranges to combine.
+	 * @returns {engine.model.Range} Combined range.
+	 */
+	static createFromRanges( ranges ) {
+		if ( ranges.length === 0 ) {
+			/**
+			 * At least one range has to be passed.
+			 *
+			 * @error range-create-from-ranges-empty-array
+			 */
+			throw new CKEditorError( 'range-create-from-ranges-empty-array: At least one range has to be passed.' );
+		} else if ( ranges.length == 1 ) {
+			return Range.createFromRange( ranges[ 0 ] );
+		}
+
+		// 1. Set the first range in `ranges` array as a reference range.
+		// If we are going to return just a one range, one of the ranges need to be the reference one.
+		// Other ranges will be stuck to that range, if possible.
+		const ref = ranges[ 0 ];
+
+		// 2. Sort all the ranges so it's easier to process them.
+		ranges.sort( ( a, b ) => a.start.isAfter( b.start ) );
+
+		// 3. Check at which index the reference range is now.
+		const refIndex = ranges.indexOf( ref );
+
+		// 4. At this moment we don't need the original range.
+		// We are going to modify the result and we need to return a new instance of Range.
+		// We have to create a copy of the reference range.
+		const result = new this( ref.start, ref.end );
+
+		// 5. Ranges before reference range should be glued starting from the "last one", that is the range
+		// that is closest to the reference range.
+		for ( let i = refIndex - 1; i >= 0; i++ ) {
+			if ( ranges[ i ].end.isTouching( result.start ) ) {
+				result.start = Position.createFromPosition( ranges[ i ].start );
+			} else {
+				// If range do not touch with reference range there is no point in looking further.
+				break;
+			}
+		}
+
+		// 5. Ranges after reference range should be glued starting from the "first one", that is the range
+		// that is closest to the reference range.
+		for ( let i = refIndex + 1; i < ranges.length; i++ ) {
+			if ( ranges[ i ].start.isTouching( result.end ) ) {
+				result.end = Position.createFromPosition( ranges[ i ].end );
+			} else {
+				// If range do not touch with reference range there is no point in looking further.
+				break;
+			}
+		}
+
+		return result;
+	}
+
 	/**
 	/**
 	 * Creates a `Range` instance from given plain object (i.e. parsed JSON string).
 	 * Creates a `Range` instance from given plain object (i.e. parsed JSON string).
 	 *
 	 *

+ 189 - 0
packages/ckeditor5-engine/tests/model/range.js

@@ -11,8 +11,18 @@ import Element from 'ckeditor5/engine/model/element.js';
 import Text from 'ckeditor5/engine/model/text.js';
 import Text from 'ckeditor5/engine/model/text.js';
 import Document from 'ckeditor5/engine/model/document.js';
 import Document from 'ckeditor5/engine/model/document.js';
 import TreeWalker from 'ckeditor5/engine/model/treewalker.js';
 import TreeWalker from 'ckeditor5/engine/model/treewalker.js';
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import { jsonParseStringify } from 'tests/engine/model/_utils/utils.js';
 import { jsonParseStringify } from 'tests/engine/model/_utils/utils.js';
 
 
+import {
+	getAttributeDelta,
+	getInsertDelta,
+	getMoveDelta,
+	getRemoveDelta,
+	getRenameDelta,
+	getSplitDelta
+} from 'tests/engine/model/delta/transform/_utils/utils.js';
+
 describe( 'Range', () => {
 describe( 'Range', () => {
 	let doc, range, start, end, root, otherRoot;
 	let doc, range, start, end, root, otherRoot;
 
 
@@ -194,6 +204,43 @@ describe( 'Range', () => {
 				expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
 				expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'createFromRanges', () => {
+			function makeRanges( root, ...points ) {
+				let ranges = [];
+
+				for ( let i = 0; i < points.length; i += 2 ) {
+					ranges.push( Range.createFromParentsAndOffsets( root, points[ i ], root, points[ i + 1 ] ) );
+				}
+
+				return ranges;
+			}
+
+			beforeEach( () => {
+				root.appendChildren( new Text( 'abcdefghijklmnopqrtuvwxyz' ) );
+			} );
+
+			it( 'should throw if empty array is passed', () => {
+				expect( () => {
+					Range.createFromRanges( [] );
+				} ).to.throw( CKEditorError, /^range-create-from-ranges-empty-array/ );
+			} );
+
+			it( 'should return a copy of the range if only one range was passed', () => {
+				const original = Range.createFromParentsAndOffsets( root, 2, root, 3 );
+				const range = Range.createFromRanges( [ original ] );
+
+				expect( range.isEqual( original ) ).to.be.true;
+				expect( range ).not.to.be.equal( original );
+			} );
+
+			it( 'should combine ranges with reference range', () => {
+				const range = Range.createFromRanges( makeRanges( root, 3, 7, 2, 3, 7, 9, 11, 14, 0, 1 ) );
+
+				expect( range.start.offset ).to.equal( 2 );
+				expect( range.end.offset ).to.equal( 9 );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'iterator', () => {
 	describe( 'iterator', () => {
@@ -662,6 +709,148 @@ describe( 'Range', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'getTransformedByDelta', () => {
+		beforeEach( () => {
+			root.appendChildren( new Text( 'foobar' ) );
+			range = Range.createFromParentsAndOffsets( root, 2, root, 5 );
+		} );
+
+		function expectRange( range, startOffset, endOffset ) {
+			expect( range.start.offset ).to.equal( startOffset );
+			expect( range.end.offset ).to.equal( endOffset );
+		}
+
+		describe( 'by AttributeDelta', () => {
+			it( 'nothing should change', () => {
+				const delta = getAttributeDelta( Range.createFromParentsAndOffsets( root, 1, root, 6 ), 'key', true, false, 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by InsertDelta', () => {
+			it( 'insert before range', () => {
+				const delta = getInsertDelta( new Position( root, [ 1 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 5, 8 );
+			} );
+
+			it( 'insert inside range', () => {
+				const delta = getInsertDelta( new Position( root, [ 3 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 8 );
+			} );
+
+			it( 'insert after range', () => {
+				const delta = getInsertDelta( new Position( root, [ 6 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by MoveDelta', () => {
+			it( 'move before range', () => {
+				const start = new Position( root, [ 0 ] );
+				const end = new Position( otherRoot, [ 0 ] );
+				const delta = getMoveDelta( start, 2, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 0, 3 );
+			} );
+
+			it( 'move intersecting with range (and targeting before range)', () => {
+				const start = new Position( root, [ 4 ] );
+				const end = new Position( root, [ 0 ] );
+				const delta = getMoveDelta( start, 2, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 4, 6 );
+				expectRange( transformed[ 1 ], 0, 1 );
+			} );
+
+			it( 'move inside the range', () => {
+				range.end.offset = 6;
+				const start = new Position( root, [ 3 ] );
+				const end = new Position( root, [ 5 ] );
+				const delta = getMoveDelta( start, 1, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 4 );
+				expectRange( transformed[ 1 ], 5, 6 );
+				expectRange( transformed[ 2 ], 4, 5 );
+			} );
+		} );
+
+		describe( 'by RemoveDelta', () => {
+			it( 'remove before range', () => {
+				const start = new Position( root, [ 0 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 0, 3 );
+			} );
+
+			it( 'remove intersecting with range', () => {
+				const start = new Position( root, [ 4 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 4 );
+				expect( transformed[ 1 ].root ).to.equal( doc.graveyard );
+				expect( transformed[ 1 ].end.offset - transformed[ 1 ].start.offset ).to.equal( 1 );
+			} );
+
+			it( 'remove inside the range', () => {
+				const start = new Position( root, [ 3 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 3 );
+				expect( transformed[ 1 ].root ).to.equal( doc.graveyard );
+				expect( transformed[ 1 ].end.offset - transformed[ 1 ].start.offset ).to.equal( 2 );
+			} );
+		} );
+
+		describe( 'by RenameDelta', () => {
+			it( 'nothing should change', () => {
+				const delta = getRenameDelta( new Position( root, [ 3 ] ), 'old', 'new', 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by SplitDelta', () => {
+			it( 'split inside range', () => {
+				root.removeChildren( root.childCount );
+				root.appendChildren( new Element( 'p', null, new Text( 'foobar' ) ) );
+
+				range.start = new Position( root, [ 0, 2 ] );
+				range.end = new Position( root, [ 0, 4 ] );
+
+				const delta = getSplitDelta( new Position( root, [ 0, 3 ] ), new Element( 'p' ), 3, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 2 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 3 ] );
+				expect( transformed[ 1 ].start.path ).to.deep.equal( [ 1, 0 ] );
+				expect( transformed[ 1 ].end.path ).to.deep.equal( [ 1, 1 ] );
+			} );
+		} );
+	} );
+
 	describe( 'getMinimalFlatRanges', () => {
 	describe( 'getMinimalFlatRanges', () => {
 		beforeEach( () => {
 		beforeEach( () => {
 			prepareRichRoot( root );
 			prepareRichRoot( root );