Преглед на файлове

Merge branch 'master' into t/259

Piotrek Koszuliński преди 9 години
родител
ревизия
a4e807cb5d

+ 27 - 1
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -6,6 +6,7 @@
 /* globals window */
 
 import Observer from './observer.js';
+import ViewSelection from '../selection.js';
 import { startsWithFiller, getDataWithoutFiller } from '../filler.js';
 
 /**
@@ -191,7 +192,30 @@ export default class MutationObserver extends Observer {
 			} );
 		}
 
-		this.document.fire( 'mutations', viewMutations );
+		// Retrieve `domSelection` using `ownerDocument` of one of mutated nodes.
+		// There should not be simultaneous mutation in multiple documents, so it's fine.
+		const domSelection = domMutations[ 0 ].target.ownerDocument.getSelection();
+
+		let viewSelection = null;
+
+		if ( domSelection && domSelection.anchorNode ) {
+			// If `domSelection` is inside a dom node that is already bound to a view node from view tree, get
+			// corresponding selection in the view and pass it together with `viewMutations`. The `viewSelection` may
+			// be used by features handling mutations.
+			// Only one range is supported.
+
+			const viewSelectionAnchor = domConverter.domPositionToView( domSelection.anchorNode, domSelection.anchorOffset );
+			const viewSelectionFocus = domConverter.domPositionToView( domSelection.focusNode, domSelection.focusOffset );
+
+			// Anchor and focus has to be properly mapped to view.
+			if ( viewSelectionAnchor && viewSelectionFocus ) {
+				viewSelection = new ViewSelection();
+				viewSelection.collapse( viewSelectionAnchor );
+				viewSelection.setFocus( viewSelectionFocus );
+			}
+		}
+
+		this.document.fire( 'mutations', viewMutations, viewSelection );
 
 		// If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
 		// view (which has not been changed). In order to "reset DOM" we render the view again.
@@ -214,6 +238,8 @@ export default class MutationObserver extends Observer {
  * Array of mutations.
  * For mutated texts it will be {@link engine.view.Document~MutatatedText} and for mutated elements it will be
  * {@link engine.view.Document~MutatatedElement}. You can recognize the type based on the `type` property.
+ * @param {engine.view.Selection|null} viewSelection View selection that is a result of converting DOM selection to view. Keep in
+ * mind that the DOM selection is already "updated", meaning that it already acknowledges changes done in mutation.
  */
 
 /**

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

@@ -343,6 +343,43 @@ export default class Selection {
 	}
 
 	/**
+	 * Sets {@link engine.view.Selection#focus} to the specified location.
+	 *
+	 * The location can be specified in the same form as {@link engine.view.Position.createAt} parameters.
+	 *
+	 * @fires engine.view.Selection#change:range
+	 * @param {engine.view.Item|engine.view.Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a {@link engine.view.Item view item}.
+	 */
+	setFocus( itemOrPosition, offset ) {
+		if ( this.anchor === null ) {
+			/**
+			 * Cannot set selection focus if there are no ranges in selection.
+			 *
+			 * @error view-selection-setFocus-no-ranges
+			 */
+			throw new CKEditorError( 'view-selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.' );
+		}
+
+		const newFocus = Position.createAt( itemOrPosition, offset );
+
+		if ( newFocus.compareWith( this.focus ) == 'same' ) {
+			return;
+		}
+
+		const anchor = this.anchor;
+
+		this._ranges.pop();
+
+		if ( newFocus.compareWith( anchor ) == 'before' ) {
+			this.addRange( new Range( newFocus, anchor ), true );
+		} else {
+			this.addRange( new Range( anchor, newFocus ) );
+		}
+	}
+
+	/**
 	 * Creates and returns an instance of `Selection` that is a clone of given selection, meaning that it has same
 	 * ranges and same direction as this selection.
 	 *

+ 36 - 0
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -113,6 +113,42 @@ describe( 'MutationObserver', () => {
 		expect( lastMutations[ 0 ].node ).to.equal( viewRoot );
 	} );
 
+	it( 'should fire mutations event with view selection instance, if dom selection can be mapped to view', ( done ) => {
+		const textNode = domEditor.childNodes[ 0 ].childNodes[ 0 ];
+		textNode.data = 'foom';
+
+		const domSelection = document.getSelection();
+		domSelection.collapse( textNode, 4 );
+
+		viewDocument.on( 'mutations', ( evt, viewMutations, viewSelection ) => {
+			expect( viewSelection.anchor.parent ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ) );
+			expect( viewSelection.anchor.offset ).to.equal( 4 );
+
+			done();
+		} );
+
+		mutationObserver.flush();
+
+		expectDomEditorNotToChange();
+	} );
+
+	it( 'should fire mutations event with viewSelection param set to null, if dom selection cannot be mapped to view', ( done ) => {
+		const textNode = domEditor.ownerDocument.createTextNode( 'foo' );
+		domEditor.childNodes[ 0 ].appendChild( textNode );
+
+		const domSelection = document.getSelection();
+		domSelection.collapse( textNode, 3 );
+
+		viewDocument.on( 'mutations', ( evt, viewMutations, viewSelection ) => {
+			expect( viewSelection ).to.be.null;
+			done();
+		} );
+
+		mutationObserver.flush();
+
+		expectDomEditorNotToChange();
+	} );
+
 	it( 'should be able to observe multiple roots', () => {
 		const domAdditionalEditor = document.getElementById( 'additional' );
 

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

@@ -12,6 +12,7 @@ import Element from '/ckeditor5/engine/view/element.js';
 import Text from '/ckeditor5/engine/view/text.js';
 import Position from '/ckeditor5/engine/view/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import count from '/ckeditor5/utils/count.js';
 
 describe( 'Selection', () => {
 	let selection;
@@ -83,6 +84,156 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'setFocus', () => {
+		it( 'keeps all existing ranges when no modifications needed', () => {
+			selection.addRange( range1 );
+			selection.setFocus( selection.focus );
+
+			expect( count( selection.getRanges() ) ).to.equal( 1 );
+		} );
+
+		it( 'throws if there are no ranges in selection', () => {
+			const endPos = Position.createAt( el, 'end' );
+
+			expect( () => {
+				selection.setFocus( endPos );
+			} ).to.throw( CKEditorError, /view-selection-setFocus-no-ranges/ );
+		} );
+
+		it( 'modifies existing collapsed selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+
+			selection.collapse( startPos );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
+		} );
+
+		it( 'makes existing collapsed selection a backward selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 0 );
+
+			selection.collapse( startPos );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'modifies existing non-collapsed selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+			const newEndPos = Position.createAt( el, 3 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+		} );
+
+		it( 'makes existing non-collapsed selection a backward selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+			const newEndPos = Position.createAt( el, 0 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'makes existing backward selection a forward selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+			const newEndPos = Position.createAt( el, 3 );
+
+			selection.addRange( new Range( startPos, endPos ), true );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+			expect( selection.isBackward ).to.be.false;
+		} );
+
+		it( 'modifies existing backward selection', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+			const newEndPos = Position.createAt( el, 0 );
+
+			selection.addRange( new Range( startPos, endPos ), true );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'modifies only the last range', () => {
+			// Offsets are chosen in this way that the order of adding ranges must count, not their document order.
+			const startPos1 = Position.createAt( el, 4 );
+			const endPos1 = Position.createAt( el, 5 );
+			const startPos2 = Position.createAt( el, 1 );
+			const endPos2 = Position.createAt( el, 2 );
+
+			const newEndPos = Position.createAt( el, 0 );
+
+			selection.addRange( new Range( startPos1, endPos1 ) );
+			selection.addRange( new Range( startPos2, endPos2 ) );
+
+			selection.setFocus( newEndPos );
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges ).to.have.lengthOf( 2 );
+			expect( ranges[ 0 ].start.compareWith( startPos1 ) ).to.equal( 'same' );
+			expect( ranges[ 0 ].end.compareWith( endPos1 ) ).to.equal( 'same' );
+
+			expect( selection.anchor.compareWith( startPos2 ) ).to.equal( 'same' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'collapses the selection when extending to the anchor', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( startPos );
+
+			expect( selection.focus.compareWith( startPos ) ).to.equal( 'same' );
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'uses Position.createAt', () => {
+			const startPos = Position.createAt( el, 1 );
+			const endPos = Position.createAt( el, 2 );
+			const newEndPos = Position.createAt( el, 4 );
+
+			const spy = sinon.stub( Position, 'createAt', () => newEndPos );
+
+			selection.addRange( new Range( startPos, endPos ) );
+			selection.setFocus( el, 'end' );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
+
+			Position.createAt.restore();
+		} );
+	} );
+
 	describe( 'isCollapsed', () => {
 		it( 'should return true when there is single collapsed range', () => {
 			const range = Range.createFromParentsAndOffsets( el, 5, el, 5 );