8
0
Эх сурвалжийг харах

Implemented ContextualBalloon#positionLimiter which replaces config.ui.balloonLimiter.

Aleksander Nowodzinski 8 жил өмнө
parent
commit
9639d1ffa5

+ 34 - 1
packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js

@@ -10,6 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import BalloonPanelView from './balloonpanelview';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 /**
  * Provides the common contextual balloon panel for the editor.
@@ -47,6 +48,28 @@ export default class ContextualBalloon extends Plugin {
 		 */
 		this.view = new BalloonPanelView();
 
+		/**
+		 * The {@link module:utils/dom/position~Options#limiter position limiter}
+		 * for the {@link view}, used when no `limiter` has been passed into {@link #add}
+		 * or {@link #updatePosition}.
+		 *
+		 * By default, a function, which obtains the farthest DOM
+		 * {@link module:engine/view/rooteditableelement~RootEditableElement}
+		 * of the {@link module:engine/view/document~Document#selection}.
+		 *
+		 * @member {module:utils/dom/position~Options#limiter} #positionLimiter
+		 */
+		this.positionLimiter = () => {
+			const view = this.editor.editing.view;
+			const editableElement = view.selection.editableElement;
+
+			if ( editableElement ) {
+				return view.domConverter.mapViewToDom( editableElement.root );
+			}
+
+			return null;
+		};
+
 		/**
 		 * Stack of the views injected into the balloon. Last one in the stack is displayed
 		 * as a content of {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#view}.
@@ -196,6 +219,16 @@ export default class ContextualBalloon extends Plugin {
 	 * @returns {module:utils/dom/position~Options}
 	 */
 	_getBalloonPosition() {
-		return this._stack.values().next().value.position;
+		let position = first( this._stack.values() ).position;
+
+		// Use the default limiter if none has been specified.
+		if ( position && !position.limiter ) {
+			// Don't modify the original options object.
+			position = Object.assign( {}, position, {
+				limiter: this.positionLimiter
+			} );
+		}
+
+		return position;
 	}
 }

+ 192 - 20
packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -9,6 +9,11 @@ import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
 import View from '../../../src/view';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
+import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement';
+import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 /* global document, Event */
 
@@ -21,7 +26,7 @@ describe( 'ContextualBalloon', () => {
 
 		return ClassicTestEditor
 			.create( editorElement, {
-				plugins: [ ContextualBalloon ]
+				plugins: [ Paragraph, ContextualBalloon ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -38,7 +43,9 @@ describe( 'ContextualBalloon', () => {
 				// Add viewA to the pane and init viewB.
 				balloon.add( {
 					view: viewA,
-					position: { target: 'fake' }
+					position: {
+						target: 'fake'
+					}
 				} );
 
 				viewB.init();
@@ -65,6 +72,51 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.view ).to.instanceof( BalloonPanelView );
 		} );
 
+		describe( 'positionLimiter', () => {
+			let doc, viewDocument, root;
+
+			beforeEach( () => {
+				doc = editor.document;
+				viewDocument = editor.editing.view;
+				root = viewDocument.getRoot();
+			} );
+
+			it( 'obtains the root of the selection', () => {
+				setModelData( doc, '<paragraph>[]bar</paragraph>' );
+
+				expect( balloon.positionLimiter() ).to.equal( viewDocument.domConverter.mapViewToDom( root ) );
+			} );
+
+			it( 'does not fail if selection has no #editableElement', () => {
+				sinon.stub( viewDocument.selection, 'editableElement' ).value( null );
+
+				expect( balloon.positionLimiter() ).to.equal( null );
+			} );
+
+			it( 'obtains the farthest root of the selection (nested editable)', () => {
+				doc.schema.registerItem( 'widget' );
+				doc.schema.registerItem( 'nestededitable' );
+
+				doc.schema.objects.add( 'widget' );
+
+				doc.schema.allow( { name: 'widget', inside: '$root' } );
+				doc.schema.allow( { name: 'nestededitable', inside: 'widget' } );
+				doc.schema.allow( { name: '$inline', inside: 'nestededitable' } );
+
+				buildModelConverter().for( editor.data.modelToView, editor.editing.modelToView )
+					.fromElement( 'widget' )
+					.toElement( () => new ViewContainerElement( 'figure', { contenteditable: 'false' } ) );
+
+				buildModelConverter().for( editor.data.modelToView, editor.editing.modelToView )
+					.fromElement( 'nestededitable' )
+					.toElement( () => new ViewEditableElement( 'figcaption', { contenteditable: 'true' } ) );
+
+				setModelData( doc, '<widget><nestededitable>[]foo</nestededitable></widget>' );
+
+				expect( balloon.positionLimiter() ).to.equal( viewDocument.domConverter.mapViewToDom( root ) );
+			} );
+		} );
+
 		it( 'should add balloon panel view to editor `body` collection', () => {
 			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.above( -1 );
 		} );
@@ -74,7 +126,10 @@ describe( 'ContextualBalloon', () => {
 
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			balloon.view.element.dispatchEvent( new Event( 'focus' ) );
@@ -91,7 +146,10 @@ describe( 'ContextualBalloon', () => {
 		it( 'should return true when given view is in stack but is not visible', () => {
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			expect( balloon.visibleView ).to.equal( viewB );
@@ -108,7 +166,65 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.view.content.length ).to.equal( 1 );
 			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA );
 			expect( balloon.view.pin.calledOnce ).to.true;
-			expect( balloon.view.pin.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } );
+			expect( balloon.view.pin.firstCall.args[ 0 ] ).to.deep.equal( {
+				target: 'fake',
+				limiter: balloon.positionLimiter
+			} );
+		} );
+
+		it( 'should use a provided limiter instead of #positionLimiter', () => {
+			balloon.remove( viewA );
+			balloon.view.pin.resetHistory();
+
+			balloon.add( {
+				view: viewB,
+				position: {
+					target: 'foo',
+					limiter: 'customLimiter'
+				}
+			} );
+
+			sinon.assert.calledWithMatch( balloon.view.pin, {
+				target: 'foo',
+				limiter: 'customLimiter'
+			} );
+		} );
+
+		it( 'should use a custom #positionLimiter', () => {
+			balloon.remove( viewA );
+			balloon.view.pin.resetHistory();
+			balloon.positionLimiter = 'customLimiter';
+
+			balloon.add( {
+				view: viewB,
+				position: {
+					target: 'foo',
+				}
+			} );
+
+			sinon.assert.calledWithMatch( balloon.view.pin, {
+				target: 'foo',
+				limiter: 'customLimiter'
+			} );
+		} );
+
+		it( 'should not alter the view data if no limiter is provided and the #positionLimiter is used', () => {
+			const data = {
+				view: viewB,
+				position: {
+					target: 'foo',
+				}
+			};
+
+			balloon.remove( viewA );
+			balloon.add( data );
+
+			expect( data ).to.deep.equal( {
+				view: viewB,
+				position: {
+					target: 'foo'
+				}
+			} );
 		} );
 
 		it( 'should pin balloon to the target element', () => {
@@ -119,7 +235,10 @@ describe( 'ContextualBalloon', () => {
 			expect( () => {
 				balloon.add( {
 					view: viewA,
-					position: { target: 'fake' }
+					position: {
+						target: 'fake',
+						limiter: balloon.positionLimiter
+					}
 				} );
 			} ).to.throw( CKEditorError, /^contextualballoon-add-view-exist/ );
 		} );
@@ -127,7 +246,10 @@ describe( 'ContextualBalloon', () => {
 		it( 'should add multiple views to he stack and display last one', () => {
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			expect( balloon.view.content.length ).to.equal( 1 );
@@ -143,11 +265,13 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.view.pin.calledTwice ).to.true;
 
 			expect( balloon.view.pin.firstCall.args[ 0 ] ).to.deep.equal( {
-				target: 'fake'
+				target: 'fake',
+				limiter: balloon.positionLimiter
 			} );
 
 			expect( balloon.view.pin.secondCall.args[ 0 ] ).to.deep.equal( {
-				target: 'fake'
+				target: 'fake',
+				limiter: balloon.positionLimiter
 			} );
 		} );
 
@@ -156,7 +280,10 @@ describe( 'ContextualBalloon', () => {
 
 			balloon.add( {
 				view,
-				position: { target: 'fake' },
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				},
 				balloonClassName: 'foo'
 			} );
 
@@ -164,7 +291,10 @@ describe( 'ContextualBalloon', () => {
 
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' },
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				},
 				balloonClassName: 'bar'
 			} );
 
@@ -180,7 +310,10 @@ describe( 'ContextualBalloon', () => {
 		it( 'should return data of currently visible view when there is more than one in the stack', () => {
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			expect( balloon.visibleView ).to.equal( viewB );
@@ -205,7 +338,10 @@ describe( 'ContextualBalloon', () => {
 		it( 'should remove given view and set preceding in the stack as visible when removed view was visible', () => {
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			balloon.remove( viewB );
@@ -216,7 +352,10 @@ describe( 'ContextualBalloon', () => {
 		it( 'should remove given view from the stack when view is not visible', () => {
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' }
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
 			} );
 
 			balloon.remove( viewA );
@@ -235,13 +374,19 @@ describe( 'ContextualBalloon', () => {
 
 			balloon.add( {
 				view,
-				position: { target: 'fake' },
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				},
 				balloonClassName: 'foo'
 			} );
 
 			balloon.add( {
 				view: viewB,
-				position: { target: 'fake' },
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				},
 				balloonClassName: 'bar'
 			} );
 
@@ -265,7 +410,10 @@ describe( 'ContextualBalloon', () => {
 			balloon.updatePosition();
 
 			expect( balloon.view.attachTo.calledOnce );
-			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
+				target: 'fake',
+				limiter: balloon.positionLimiter
+			} );
 		} );
 
 		it( 'should set given position to the currently visible view and use position from the first view in the stack #1', () => {
@@ -274,7 +422,10 @@ describe( 'ContextualBalloon', () => {
 			balloon.updatePosition( { target: 'new' } );
 
 			expect( balloon.view.attachTo.calledOnce );
-			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'new' } );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
+				target: 'new',
+				limiter: balloon.positionLimiter
+			} );
 		} );
 
 		it( 'should set given position to the currently visible view and use position from the first view in the stack #2', () => {
@@ -290,14 +441,35 @@ describe( 'ContextualBalloon', () => {
 			balloon.updatePosition( { target: 'new' } );
 
 			expect( balloon.view.attachTo.calledOnce );
-			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
+				target: 'fake',
+				limiter: balloon.positionLimiter
+			} );
 
 			balloon.remove( viewA );
 
 			balloon.updatePosition();
 
 			expect( balloon.view.attachTo.calledTwice );
-			expect( balloon.view.attachTo.secondCall.args[ 0 ] ).to.deep.equal( { target: 'new' } );
+			expect( balloon.view.attachTo.secondCall.args[ 0 ] ).to.deep.equal( {
+				target: 'new',
+				limiter: balloon.positionLimiter
+			} );
+		} );
+
+		it( 'should use a given position limiter instead of the default one', () => {
+			balloon.view.attachTo.reset();
+
+			balloon.updatePosition( {
+				target: 'new',
+				limiter: 'customLimiter'
+			} );
+
+			expect( balloon.view.attachTo.calledOnce );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
+				target: 'new',
+				limiter: 'customLimiter'
+			} );
 		} );
 
 		it( 'should throw an error when there is no given view in the stack', () => {