8
0
Просмотр исходного кода

Merge branch 'master' into t/193

Aleksander Nowodzinski 8 лет назад
Родитель
Сommit
09fa23e949

+ 1 - 1
packages/ckeditor5-ui/package.json

@@ -10,7 +10,7 @@
     "@ckeditor/ckeditor5-utils": "^0.9.0"
     "@ckeditor/ckeditor5-utils": "^0.9.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@ckeditor/ckeditor5-basic-styles": "^0.7.1",
+    "@ckeditor/ckeditor5-basic-styles": "^0.8.0",
     "@ckeditor/ckeditor5-core": "^0.8.0",
     "@ckeditor/ckeditor5-core": "^0.8.0",
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^0.7.2",
     "@ckeditor/ckeditor5-editor-classic": "^0.7.2",

+ 3 - 3
packages/ckeditor5-ui/src/notification/notification.js

@@ -4,7 +4,7 @@
  */
  */
 
 
 /**
 /**
- * @module ui/notification
+ * @module ui/notification/notification
  */
  */
 
 
 /* globals window */
 /* globals window */
@@ -19,7 +19,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  * notifications makes possible to switch the notifications UI.
  * notifications makes possible to switch the notifications UI.
  *
  *
  * Note that every unhandled and not stopped `warning` notification will be displayed as system alert.
  * Note that every unhandled and not stopped `warning` notification will be displayed as system alert.
- * See {@link module:ui/notification~Notification#showWarning}.
+ * See {@link module:ui/notification/notification~Notification#showWarning}.
  *
  *
  * @extends module:core/plugin~Plugin
  * @extends module:core/plugin~Plugin
  */
  */
@@ -28,7 +28,7 @@ export default class Notification extends Plugin {
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	static get pluginName() {
 	static get pluginName() {
-		return 'notification';
+		return 'ui/notification';
 	}
 	}
 
 
 	/**
 	/**

+ 85 - 23
packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js

@@ -52,18 +52,22 @@ export default class BalloonPanelView extends View {
 
 
 		/**
 		/**
 		 * Balloon panel's current position. The position name is reflected in the CSS class set
 		 * Balloon panel's current position. The position name is reflected in the CSS class set
-		 * to the balloon, i.e. `.ck-balloon-panel_arrow_se` for "se" position. The class
+		 * to the balloon, i.e. `.ck-balloon-panel_arrow_se` for "arrow_se" position. The class
 		 * controls the minor aspects of the balloon's visual appearance like placement
 		 * controls the minor aspects of the balloon's visual appearance like placement
 		 * of the "arrow". To support a new position, an additional CSS must be created.
 		 * of the "arrow". To support a new position, an additional CSS must be created.
 		 *
 		 *
 		 * Default position names correspond with
 		 * Default position names correspond with
 		 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 		 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 		 *
 		 *
+		 * See {@link #attachTo} to learn about custom balloon positions.
+		 *
+		 * See {@link #withArrow}.
+		 *
 		 * @observable
 		 * @observable
-		 * @default 'se'
-		 * @member {'se'|'sw'|'ne'|'nw'} #position
+		 * @default 'arrow_se'
+		 * @member {'arrow_se'|'arrow_sw'|'arrow_ne'|'arrow_nw'} #position
 		 */
 		 */
-		this.set( 'position', 'se' );
+		this.set( 'position', 'arrow_se' );
 
 
 		/**
 		/**
 		 * Controls whether the balloon panel is visible or not.
 		 * Controls whether the balloon panel is visible or not.
@@ -74,6 +78,16 @@ export default class BalloonPanelView extends View {
 		 */
 		 */
 		this.set( 'isVisible', false );
 		this.set( 'isVisible', false );
 
 
+		/**
+		 * Controls whether the balloon panel has an arrow. The presence of the arrow
+		 * is reflected in `ck-balloon-panel_with-arrow` CSS class.
+		 *
+		 * @observable
+		 * @default true
+		 * @member {Boolean} #withArrow
+		 */
+		this.set( 'withArrow', true );
+
 		/**
 		/**
 		 * Max width of the balloon panel, as in CSS.
 		 * Max width of the balloon panel, as in CSS.
 		 *
 		 *
@@ -81,6 +95,14 @@ export default class BalloonPanelView extends View {
 		 * @member {Number} #maxWidth
 		 * @member {Number} #maxWidth
 		 */
 		 */
 
 
+		/**
+		 * A callback that starts pining the panel when {@link #isVisible} gets
+		 * `true`. Used by {@link #pin}.
+		 *
+		 * @private
+		 * @member {Function} #_pinWhenIsVisibleCallback
+		 */
+
 		/**
 		/**
 		 * Collection of the child views which creates balloon panel contents.
 		 * Collection of the child views which creates balloon panel contents.
 		 *
 		 *
@@ -94,8 +116,9 @@ export default class BalloonPanelView extends View {
 			attributes: {
 			attributes: {
 				class: [
 				class: [
 					'ck-balloon-panel',
 					'ck-balloon-panel',
-					bind.to( 'position', ( value ) => `ck-balloon-panel_arrow_${ value }` ),
-					bind.if( 'isVisible', 'ck-balloon-panel_visible' )
+					bind.to( 'position', ( value ) => `ck-balloon-panel_${ value }` ),
+					bind.if( 'isVisible', 'ck-balloon-panel_visible' ),
+					bind.if( 'withArrow', 'ck-balloon-panel_with-arrow' )
 				],
 				],
 
 
 				style: {
 				style: {
@@ -170,14 +193,57 @@ export default class BalloonPanelView extends View {
 	 *
 	 *
 	 * Thanks to this, the panel always sticks to the {@link module:utils/dom/position~Options#target}.
 	 * Thanks to this, the panel always sticks to the {@link module:utils/dom/position~Options#target}.
 	 *
 	 *
-	 * See https://github.com/ckeditor/ckeditor5-ui/issues/170.
+	 * See: {@link #unpin}.
 	 *
 	 *
 	 * @param {module:utils/dom/position~Options} options Positioning options compatible with
 	 * @param {module:utils/dom/position~Options} options Positioning options compatible with
 	 * {@link module:utils/dom/position~getOptimalPosition}. Default `positions` array is
 	 * {@link module:utils/dom/position~getOptimalPosition}. Default `positions` array is
 	 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 	 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
 	 */
 	 */
-	keepAttachedTo( options ) {
-		// First we need to attach the balloon panel to the target element.
+	pin( options ) {
+		this.unpin();
+
+		this._pinWhenIsVisibleCallback = () => {
+			if ( this.isVisible ) {
+				this._startPinning( options );
+			} else {
+				this._stopPinning();
+			}
+		};
+
+		this._startPinning( options );
+
+		// Control the state of the listeners depending on whether the panel is visible
+		// or not.
+		// TODO: Use on() (https://github.com/ckeditor/ckeditor5-utils/issues/144).
+		this.listenTo( this, 'change:isVisible', this._pinWhenIsVisibleCallback );
+	}
+
+	/**
+	 * Stops pinning the panel, as set up by {@link #pin}.
+	 */
+	unpin() {
+		if ( this._pinWhenIsVisibleCallback ) {
+			// Deactivate listeners attached by pin().
+			this._stopPinning();
+
+			// Deactivate the panel pin() control logic.
+			// TODO: Use off() (https://github.com/ckeditor/ckeditor5-utils/issues/144).
+			this.stopListening( this, 'change:isVisible', this._pinWhenIsVisibleCallback );
+
+			this._pinWhenIsVisibleCallback = null;
+
+			this.hide();
+		}
+	}
+
+	/**
+	 * Starts managing the pinned state of the panel. See {@link #pin}.
+	 *
+	 * @private
+	 * @param {module:utils/dom/position~Options} options Positioning options compatible with
+	 * {@link module:utils/dom/position~getOptimalPosition}.
+	 */
+	_startPinning( options ) {
 		this.attachTo( options );
 		this.attachTo( options );
 
 
 		const limiter = options.limiter || defaultLimiterElement;
 		const limiter = options.limiter || defaultLimiterElement;
@@ -199,23 +265,19 @@ export default class BalloonPanelView extends View {
 		}, { useCapture: true } );
 		}, { useCapture: true } );
 
 
 		// We need to listen on window resize event and update position.
 		// We need to listen on window resize event and update position.
-		this.listenTo( global.window, 'resize', () => this.attachTo( options ) );
-
-		// After all we need to clean up the listeners.
-		this.once( 'change:isVisible', () => {
-			this.stopListening( global.document, 'scroll' );
-			this.stopListening( global.window, 'resize' );
+		this.listenTo( global.window, 'resize', () => {
+			this.attachTo( options );
 		} );
 		} );
 	}
 	}
 
 
 	/**
 	/**
-	 * @inheritDoc
+	 * Stops managing the pinned state of the panel. See {@link #pin}.
+	 *
+	 * @private
 	 */
 	 */
-	destroy() {
+	_stopPinning() {
 		this.stopListening( global.document, 'scroll' );
 		this.stopListening( global.document, 'scroll' );
 		this.stopListening( global.window, 'resize' );
 		this.stopListening( global.window, 'resize' );
-
-		return super.destroy();
 	}
 	}
 }
 }
 
 
@@ -310,24 +372,24 @@ BalloonPanelView.defaultPositions = {
 	se: ( targetRect ) => ( {
 	se: ( targetRect ) => ( {
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
-		name: 'se'
+		name: 'arrow_se'
 	} ),
 	} ),
 
 
 	sw: ( targetRect, balloonRect ) => ( {
 	sw: ( targetRect, balloonRect ) => ( {
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
-		name: 'sw'
+		name: 'arrow_sw'
 	} ),
 	} ),
 
 
 	ne: ( targetRect, balloonRect ) => ( {
 	ne: ( targetRect, balloonRect ) => ( {
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
-		name: 'ne'
+		name: 'arrow_ne'
 	} ),
 	} ),
 
 
 	nw: ( targetRect, balloonRect ) => ( {
 	nw: ( targetRect, balloonRect ) => ( {
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
-		name: 'nw'
+		name: 'arrow_nw'
 	} )
 	} )
 };
 };

+ 21 - 11
packages/ckeditor5-ui/src/contextualballoon.js → packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js

@@ -4,11 +4,11 @@
  */
  */
 
 
 /**
 /**
- * @module ui/contextualballoon
+ * @module ui/panel/balloon/contextualballoon
  */
  */
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import BalloonPanelView from './panel/balloon/balloonpanelview';
+import BalloonPanelView from './balloonpanelview';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 /**
 /**
@@ -42,7 +42,7 @@ export default class ContextualBalloon extends Plugin {
 
 
 		/**
 		/**
 		 * Stack of the views injected into the balloon. Last one in the stack is displayed
 		 * Stack of the views injected into the balloon. Last one in the stack is displayed
-		 * as a content of {@link module:ui/contextualballoon~ContextualBalloon#view}.
+		 * as a content of {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#view}.
 		 *
 		 *
 		 * @private
 		 * @private
 		 * @member {Map} #_stack
 		 * @member {Map} #_stack
@@ -52,8 +52,8 @@ export default class ContextualBalloon extends Plugin {
 		// Editor should be focused when contextual balloon is focused.
 		// Editor should be focused when contextual balloon is focused.
 		this.editor.ui.focusTracker.add( this.view.element );
 		this.editor.ui.focusTracker.add( this.view.element );
 
 
-		// Add balloon panel view to editor `body` collection.
-		this.editor.ui.view.body.add( this.view );
+		// Add balloon panel view to editor `body` collection and wait until view will be ready.
+		return this.editor.ui.view.body.add( this.view );
 	}
 	}
 
 
 	static get pluginName() {
 	static get pluginName() {
@@ -86,8 +86,9 @@ export default class ContextualBalloon extends Plugin {
 	 * Adds a new view to the stack and makes it visible.
 	 * Adds a new view to the stack and makes it visible.
 	 *
 	 *
 	 * @param {Object} data Configuration of the view.
 	 * @param {Object} data Configuration of the view.
-	 * @param {module:ui/view~View} view Content of the balloon.
-	 * @param {module:utils/dom/position~Options} position Positioning options.
+	 * @param {module:ui/view~View} [data.view] Content of the balloon.
+	 * @param {module:utils/dom/position~Options} [data.position] Positioning options.
+	 * @returns {Promise} A Promise resolved when the child {@link module:ui/view~View#init} is done.
 	 */
 	 */
 	add( data ) {
 	add( data ) {
 		if ( this.hasView( data.view ) ) {
 		if ( this.hasView( data.view ) ) {
@@ -108,7 +109,7 @@ export default class ContextualBalloon extends Plugin {
 		// Add new view to the stack.
 		// Add new view to the stack.
 		this._stack.set( data.view, data );
 		this._stack.set( data.view, data );
 		// And display it.
 		// And display it.
-		this._show( data.view );
+		return this._show( data.view );
 	}
 	}
 
 
 	/**
 	/**
@@ -117,6 +118,7 @@ export default class ContextualBalloon extends Plugin {
 	 * When there is no view in the stack then balloon will hide.
 	 * When there is no view in the stack then balloon will hide.
 	 *
 	 *
 	 * @param {module:ui/view~View} view A view to be removed from the balloon.
 	 * @param {module:ui/view~View} view A view to be removed from the balloon.
+	 * @returns {Promise} A Promise resolved when the preceding view is ready.
 	 */
 	 */
 	remove( view ) {
 	remove( view ) {
 		if ( !this.hasView( view ) ) {
 		if ( !this.hasView( view ) ) {
@@ -128,6 +130,9 @@ export default class ContextualBalloon extends Plugin {
 			throw new CKEditorError( 'contextualballoon-remove-view-not-exist: Cannot remove configuration of not existing view.' );
 			throw new CKEditorError( 'contextualballoon-remove-view-not-exist: Cannot remove configuration of not existing view.' );
 		}
 		}
 
 
+		// A Promise resolved when the preceding view is ready.
+		let promise = Promise.resolve();
+
 		// When visible view is being removed.
 		// When visible view is being removed.
 		if ( this.visibleView === view ) {
 		if ( this.visibleView === view ) {
 			// We need to remove it from the view content.
 			// We need to remove it from the view content.
@@ -142,7 +147,7 @@ export default class ContextualBalloon extends Plugin {
 			// If it is some other view.
 			// If it is some other view.
 			if ( last ) {
 			if ( last ) {
 				// Just show it.
 				// Just show it.
-				this._show( last.view );
+				promise = this._show( last.view );
 			} else {
 			} else {
 				// Hide the balloon panel.
 				// Hide the balloon panel.
 				this.view.hide();
 				this.view.hide();
@@ -151,6 +156,8 @@ export default class ContextualBalloon extends Plugin {
 			// Just remove given view from the stack.
 			// Just remove given view from the stack.
 			this._stack.delete( view );
 			this._stack.delete( view );
 		}
 		}
+
+		return promise;
 	}
 	}
 
 
 	/**
 	/**
@@ -167,10 +174,12 @@ export default class ContextualBalloon extends Plugin {
 	 *
 	 *
 	 * @private
 	 * @private
 	 * @param {module:ui/view~View} view View to show in the balloon.
 	 * @param {module:ui/view~View} view View to show in the balloon.
+	 * @returns {Promise} A Promise resolved when the child {@link module:ui/view~View#init} is done.
 	 */
 	 */
 	_show( view ) {
 	_show( view ) {
-		this.view.content.add( view );
-		this.view.attachTo( this._getBalloonPosition() );
+		return this.view.content.add( view ).then( () => {
+			this.view.attachTo( this._getBalloonPosition() );
+		} );
 	}
 	}
 
 
 	/**
 	/**
@@ -190,6 +199,7 @@ export default class ContextualBalloon extends Plugin {
 	destroy() {
 	destroy() {
 		this.editor.ui.view.body.remove( this.view );
 		this.editor.ui.view.body.remove( this.view );
 		this.view.destroy();
 		this.view.destroy();
+		this._stack.clear();
 		super.destroy();
 		super.destroy();
 	}
 	}
 }
 }

+ 0 - 196
packages/ckeditor5-ui/src/panel/floating/floatingpanelview.js

@@ -1,196 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module ui/panel/floating/floatingpanelview
- */
-
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import Template from '../../template';
-import View from '../../view';
-import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
-import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
-
-const toPx = toUnit( 'px' );
-
-/**
- * The floating panel view class. It floats around the
- * {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView#targetElement} in DOM
- * to remain visible in the browser viewport.
- *
- * See {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView.defaultPositions}
- * to learn about the positioning.
- *
- * @extends module:ui/view~View
- */
-export default class FloatingPanelView extends View {
-	/**
-	 * @inheritDoc
-	 */
-	constructor( locale ) {
-		super( locale );
-
-		const bind = this.bindTemplate;
-
-		/**
-		 * Controls whether the floating panel is active. When any editable
-		 * is focused in the editor, panel becomes active.
-		 *
-		 * @readonly
-		 * @observable
-		 * @member {Boolean} #isActive
-		 */
-		this.set( 'isActive', false );
-
-		/**
-		 * The absolute top position of the panel, in pixels.
-		 *
-		 * @observable
-		 * @default 0
-		 * @member {Number} #top
-		 */
-		this.set( 'top', 0 );
-
-		/**
-		 * The absolute left position of the panel, in pixels.
-		 *
-		 * @observable
-		 * @default 0
-		 * @member {Number} #left
-		 */
-		this.set( 'left', 0 );
-
-		/**
-		 * An element with respect to which the panel is positioned.
-		 *
-		 * @readonly
-		 * @observable
-		 * @member {HTMLElement} #targetElement
-		 */
-		this.set( 'targetElement', null );
-
-		/**
-		 * Collection of the child views which creates balloon panel contents.
-		 *
-		 * @readonly
-		 * @member {module:ui/viewcollection~ViewCollection}
-		 */
-		this.content = this.createCollection();
-
-		this.template = new Template( {
-			tag: 'div',
-			attributes: {
-				class: [
-					'ck-floating-panel',
-					bind.if( 'isActive', 'ck-floating-panel_active' ),
-				],
-				style: {
-					top: bind.to( 'top', toPx ),
-					left: bind.to( 'left', toPx ),
-				}
-			},
-
-			children: this.content
-		} );
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		this.listenTo( global.window, 'scroll', () => this._updatePosition() );
-		this.listenTo( this, 'change:isActive', () => this._updatePosition() );
-
-		return super.init();
-	}
-
-	/**
-	 * Analyzes the environment to decide where the panel should
-	 * be positioned.
-	 *
-	 * @protected
-	 */
-	_updatePosition() {
-		if ( !this.isActive ) {
-			return;
-		}
-
-		const { nw, sw, ne, se } = FloatingPanelView.defaultPositions;
-		const { top, left } = getOptimalPosition( {
-			element: this.element,
-			target: this.targetElement,
-			positions: [ nw, sw, ne, se ],
-			limiter: global.document.body,
-			fitInViewport: true
-		} );
-
-		Object.assign( this, { top, left } );
-	}
-}
-
-/**
- * A default set of positioning functions used by the panel view to float
- * around {@link module:ui/panel/floating/floatingpanelview~FloatingPanelView#targetElement}.
- *
- * The available positioning functions are as follows:
- *
- * * South east:
- *
- *		+----------------+
- *		| #targetElement |
- *		+----------------+
- *		         [ Panel ]
- *
- * * South west:
- *
- *		+----------------+
- *		| #targetElement |
- *		+----------------+
- *		[ Panel ]
- *
- * * North east:
- *
- *		         [ Panel ]
- *		+----------------+
- *		| #targetElement |
- *		+----------------+
- *
- *
- * * North west:
- *
- *		[ Panel ]
- *		+----------------+
- *		| #targetElement |
- *		+----------------+
- *
- * Positioning functions must be compatible with {@link module:utils/dom/position~Position}.
- *
- * @member {Object} module:ui/panel/floating/floatingpanelview~FloatingPanelView.defaultPositions
- */
-FloatingPanelView.defaultPositions = {
-	nw: ( targetRect, panelRect ) => ( {
-		top: targetRect.top - panelRect.height,
-		left: targetRect.left,
-		name: 'nw'
-	} ),
-
-	sw: ( targetRect ) => ( {
-		top: targetRect.bottom,
-		left: targetRect.left,
-		name: 'sw'
-	} ),
-
-	ne: ( targetRect, panelRect ) => ( {
-		top: targetRect.top - panelRect.height,
-		left: targetRect.left + targetRect.width - panelRect.width,
-		name: 'ne'
-	} ),
-
-	se: ( targetRect, panelRect ) => ( {
-		top: targetRect.bottom,
-		left: targetRect.left + targetRect.width - panelRect.width,
-		name: 'se'
-	} )
-};

+ 3 - 3
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.js

@@ -16,7 +16,7 @@ import EssentialsPresets from '@ckeditor/ckeditor5-presets/src/essentials';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
-import ContextualBalloon from '../../../src/contextualballoon';
+import ContextualBalloon from '../../../src/panel/balloon/contextualballoon';
 import ToolbarView from '../../../src/toolbar/toolbarview';
 import ToolbarView from '../../../src/toolbar/toolbarview';
 import ButtonView from '../../../src/button/buttonview';
 import ButtonView from '../../../src/button/buttonview';
 import Template from '../../../src/template';
 import Template from '../../../src/template';
@@ -230,13 +230,13 @@ function createContextualToolbar( editor ) {
 		forwardSelection: ( targetRect, balloonRect ) => ( {
 		forwardSelection: ( targetRect, balloonRect ) => ( {
 			top: targetRect.bottom + arrowVOffset,
 			top: targetRect.bottom + arrowVOffset,
 			left: targetRect.right - balloonRect.width / 2,
 			left: targetRect.right - balloonRect.width / 2,
-			name: 's'
+			name: 'arrow_s'
 		} ),
 		} ),
 
 
 		backwardSelection: ( targetRect, balloonRect ) => ( {
 		backwardSelection: ( targetRect, balloonRect ) => ( {
 			top: targetRect.top - balloonRect.height - arrowVOffset,
 			top: targetRect.top - balloonRect.height - arrowVOffset,
 			left: targetRect.left - balloonRect.width / 2,
 			left: targetRect.left - balloonRect.width / 2,
-			name: 'n'
+			name: 'arrow_n'
 		} )
 		} )
 	};
 	};
 
 

+ 2 - 2
packages/ckeditor5-ui/tests/manual/contextualtoolbar/contextualtoolbar.js

@@ -28,7 +28,7 @@ const positions = {
 	forwardSelection: ( targetRect, balloonRect ) => ( {
 	forwardSelection: ( targetRect, balloonRect ) => ( {
 		top: targetRect.bottom + arrowVOffset,
 		top: targetRect.bottom + arrowVOffset,
 		left: targetRect.right - balloonRect.width / 2,
 		left: targetRect.right - balloonRect.width / 2,
-		name: 's'
+		name: 'arrow_s'
 	} ),
 	} ),
 
 
 	//	+-----------------+
 	//	+-----------------+
@@ -39,7 +39,7 @@ const positions = {
 	backwardSelection: ( targetRect, balloonRect ) => ( {
 	backwardSelection: ( targetRect, balloonRect ) => ( {
 		top: targetRect.top - balloonRect.height - arrowVOffset,
 		top: targetRect.top - balloonRect.height - arrowVOffset,
 		left: targetRect.left - balloonRect.width / 2,
 		left: targetRect.left - balloonRect.width / 2,
-		name: 'n'
+		name: 'arrow_n'
 	} )
 	} )
 };
 };
 
 

+ 1 - 1
packages/ckeditor5-ui/tests/manual/tickets/170/1.js

@@ -52,7 +52,7 @@ ClassicEditor.create( document.querySelector( '#editor-stick' ), {
 	editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360;
 	editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360;
 
 
 	panel.init().then( () => {
 	panel.init().then( () => {
-		panel.keepAttachedTo( {
+		panel.pin( {
 			target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ),
 			target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ),
 			limiter: editor.ui.view.editableElement
 			limiter: editor.ui.view.editableElement
 		} );
 		} );

+ 1 - 1
packages/ckeditor5-ui/tests/manual/tickets/170/1.md

@@ -1,4 +1,4 @@
-## BalloonPanelView `attachTo` vs `keepAttachedTo`
+## BalloonPanelView `attachTo` vs `pin`
 
 
 Scroll editable elements and container (horizontally as well). Balloon in the left editor should float but balloon in the
 Scroll editable elements and container (horizontally as well). Balloon in the left editor should float but balloon in the
 right editor should stick to the target element.
 right editor should stick to the target element.

+ 180 - 80
packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js

@@ -25,6 +25,12 @@ describe( 'BalloonPanelView', () => {
 		return view.init();
 		return view.init();
 	} );
 	} );
 
 
+	afterEach( () => {
+		if ( view ) {
+			return view.destroy();
+		}
+	} );
+
 	describe( 'constructor()', () => {
 	describe( 'constructor()', () => {
 		it( 'should create element from template', () => {
 		it( 'should create element from template', () => {
 			expect( view.element.tagName ).to.equal( 'DIV' );
 			expect( view.element.tagName ).to.equal( 'DIV' );
@@ -35,8 +41,9 @@ describe( 'BalloonPanelView', () => {
 		it( 'should set default values', () => {
 		it( 'should set default values', () => {
 			expect( view.top ).to.equal( 0 );
 			expect( view.top ).to.equal( 0 );
 			expect( view.left ).to.equal( 0 );
 			expect( view.left ).to.equal( 0 );
-			expect( view.position ).to.equal( 'se' );
+			expect( view.position ).to.equal( 'arrow_se' );
 			expect( view.isVisible ).to.equal( false );
 			expect( view.isVisible ).to.equal( false );
+			expect( view.withArrow ).to.equal( true );
 		} );
 		} );
 
 
 		it( 'creates view#content collection', () => {
 		it( 'creates view#content collection', () => {
@@ -49,10 +56,18 @@ describe( 'BalloonPanelView', () => {
 			it( 'should react on view#position', () => {
 			it( 'should react on view#position', () => {
 				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_se' ) ).to.true;
 				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_se' ) ).to.true;
 
 
-				view.position = 'sw';
+				view.position = 'arrow_sw';
 
 
 				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_sw' ) ).to.true;
 				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_sw' ) ).to.true;
 			} );
 			} );
+
+			it( 'should react on view#withArrow', () => {
+				expect( view.element.classList.contains( 'ck-balloon-panel_with-arrow' ) ).to.be.true;
+
+				view.withArrow = false;
+
+				expect( view.element.classList.contains( 'ck-balloon-panel_with-arrow' ) ).to.be.false;
+			} );
 		} );
 		} );
 
 
 		describe( 'isVisible', () => {
 		describe( 'isVisible', () => {
@@ -96,9 +111,10 @@ describe( 'BalloonPanelView', () => {
 				expect( view.element.childNodes.length ).to.equal( 0 );
 				expect( view.element.childNodes.length ).to.equal( 0 );
 
 
 				const button = new ButtonView( { t() {} } );
 				const button = new ButtonView( { t() {} } );
-				view.content.add( button );
 
 
-				expect( view.element.childNodes.length ).to.equal( 1 );
+				return view.content.add( button ).then( () => {
+					expect( view.element.childNodes.length ).to.equal( 1 );
+				} );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
@@ -203,7 +219,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'se' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `south east` side of the target element when target is on the top left side of the limiter', () => {
 			it( 'should put balloon on the `south east` side of the target element when target is on the top left side of the limiter', () => {
@@ -216,7 +232,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'se' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `south west` side of the target element when target is on the right side of the limiter', () => {
 			it( 'should put balloon on the `south west` side of the target element when target is on the right side of the limiter', () => {
@@ -229,7 +245,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'sw' );
+				expect( view.position ).to.equal( 'arrow_sw' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `north east` side of the target element when target is on the bottom of the limiter ', () => {
 			it( 'should put balloon on the `north east` side of the target element when target is on the bottom of the limiter ', () => {
@@ -242,7 +258,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'ne' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `north west` side of the target element when target is on the bottom right of the limiter', () => {
 			it( 'should put balloon on the `north west` side of the target element when target is on the bottom right of the limiter', () => {
@@ -255,7 +271,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'nw' );
+				expect( view.position ).to.equal( 'arrow_nw' );
 			} );
 			} );
 
 
 			// https://github.com/ckeditor/ckeditor5-ui-default/issues/126
 			// https://github.com/ckeditor/ckeditor5-ui-default/issues/126
@@ -336,7 +352,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'sw' );
+				expect( view.position ).to.equal( 'arrow_sw' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `south east` position when `south west` is limited', () => {
 			it( 'should put balloon on the `south east` position when `south west` is limited', () => {
@@ -356,7 +372,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'se' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `north east` position when `south east` is limited', () => {
 			it( 'should put balloon on the `north east` position when `south east` is limited', () => {
@@ -380,7 +396,7 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'ne' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 			} );
 
 
 			it( 'should put balloon on the `south east` position when `north east` is limited', () => {
 			it( 'should put balloon on the `south east` position when `north east` is limited', () => {
@@ -400,21 +416,23 @@ describe( 'BalloonPanelView', () => {
 
 
 				view.attachTo( { target, limiter } );
 				view.attachTo( { target, limiter } );
 
 
-				expect( view.position ).to.equal( 'se' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'keepAttachedTo()', () => {
+	describe( 'pin() and unpin()', () => {
 		let attachToSpy, target, targetParent, limiter, notRelatedElement;
 		let attachToSpy, target, targetParent, limiter, notRelatedElement;
 
 
 		beforeEach( () => {
 		beforeEach( () => {
-			attachToSpy = testUtils.sinon.spy( view, 'attachTo' );
+			attachToSpy = sinon.spy( view, 'attachTo' );
 			limiter = document.createElement( 'div' );
 			limiter = document.createElement( 'div' );
 			targetParent = document.createElement( 'div' );
 			targetParent = document.createElement( 'div' );
 			target = document.createElement( 'div' );
 			target = document.createElement( 'div' );
 			notRelatedElement = document.createElement( 'div' );
 			notRelatedElement = document.createElement( 'div' );
 
 
+			view.show();
+
 			targetParent.appendChild( target );
 			targetParent.appendChild( target );
 			document.body.appendChild( targetParent );
 			document.body.appendChild( targetParent );
 			document.body.appendChild( limiter );
 			document.body.appendChild( limiter );
@@ -422,112 +440,194 @@ describe( 'BalloonPanelView', () => {
 		} );
 		} );
 
 
 		afterEach( () => {
 		afterEach( () => {
-			attachToSpy.restore();
+			targetParent.remove();
 			limiter.remove();
 			limiter.remove();
 			notRelatedElement.remove();
 			notRelatedElement.remove();
 		} );
 		} );
 
 
-		it( 'should keep the balloon attached to the target when any of the related elements is scrolled', () => {
-			view.keepAttachedTo( { target, limiter } );
+		describe( 'pin()', () => {
+			it( 'should show the balloon', () => {
+				const spy = sinon.spy( view, 'show' );
+
+				view.hide();
 
 
-			sinon.assert.calledOnce( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+				view.pin( { target, limiter } );
+				sinon.assert.calledOnce( spy );
+			} );
 
 
-			targetParent.dispatchEvent( new Event( 'scroll' ) );
+			it( 'should start pinning when the balloon is visible', () => {
+				view.pin( { target, limiter } );
+				sinon.assert.calledOnce( attachToSpy );
 
 
-			sinon.assert.calledTwice( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+				view.hide();
+				targetParent.dispatchEvent( new Event( 'scroll' ) );
 
 
-			limiter.dispatchEvent( new Event( 'scroll' ) );
+				view.show();
+				sinon.assert.calledTwice( attachToSpy );
 
 
-			sinon.assert.calledThrice( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+				targetParent.dispatchEvent( new Event( 'scroll' ) );
+				sinon.assert.calledThrice( attachToSpy );
+			} );
 
 
-			notRelatedElement.dispatchEvent( new Event( 'scroll' ) );
+			it( 'should stop pinning when the balloon becomes invisible', () => {
+				view.show();
 
 
-			// Nothing's changed.
-			sinon.assert.calledThrice( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
-		} );
+				view.pin( { target, limiter } );
+				sinon.assert.calledOnce( attachToSpy );
 
 
-		it( 'should keep the balloon attached to the target when the browser window is being resized', () => {
-			view.keepAttachedTo( { target, limiter } );
+				view.hide();
 
 
-			sinon.assert.calledOnce( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+				targetParent.dispatchEvent( new Event( 'scroll' ) );
+				sinon.assert.calledOnce( attachToSpy );
+			} );
 
 
-			window.dispatchEvent( new Event( 'resize' ) );
+			it( 'should unpin if already pinned', () => {
+				const unpinSpy = testUtils.sinon.spy( view, 'unpin' );
 
 
-			sinon.assert.calledTwice( attachToSpy );
-			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
-		} );
+				view.show();
+				sinon.assert.notCalled( attachToSpy );
 
 
-		it( 'should stop attaching when the balloon is hidden', () => {
-			view.keepAttachedTo( { target, limiter } );
+				view.pin( { target, limiter } );
+				sinon.assert.calledOnce( attachToSpy );
 
 
-			sinon.assert.calledOnce( attachToSpy );
+				view.pin( { target, limiter } );
+				sinon.assert.calledTwice( unpinSpy );
 
 
-			view.hide();
+				targetParent.dispatchEvent( new Event( 'scroll' ) );
+				sinon.assert.calledThrice( attachToSpy );
+			} );
 
 
-			window.dispatchEvent( new Event( 'resize' ) );
-			window.dispatchEvent( new Event( 'scroll' ) );
+			it( 'should keep the balloon pinned to the target when any of the related elements is scrolled', () => {
+				view.pin( { target, limiter } );
 
 
-			// Still once.
-			sinon.assert.calledOnce( attachToSpy );
-		} );
+				sinon.assert.calledOnce( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
 
 
-		it( 'should stop attaching once the view is destroyed', () => {
-			view.keepAttachedTo( { target, limiter } );
+				targetParent.dispatchEvent( new Event( 'scroll' ) );
 
 
-			sinon.assert.calledOnce( attachToSpy );
+				sinon.assert.calledTwice( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
 
 
-			view.destroy();
+				limiter.dispatchEvent( new Event( 'scroll' ) );
 
 
-			window.dispatchEvent( new Event( 'resize' ) );
-			window.dispatchEvent( new Event( 'scroll' ) );
+				sinon.assert.calledThrice( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
 
 
-			// Still once.
-			sinon.assert.calledOnce( attachToSpy );
-		} );
+				notRelatedElement.dispatchEvent( new Event( 'scroll' ) );
 
 
-		it( 'should set document.body as the default limiter', () => {
-			view.keepAttachedTo( { target } );
+				// Nothing's changed.
+				sinon.assert.calledThrice( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+			} );
 
 
-			sinon.assert.calledOnce( attachToSpy );
+			it( 'should keep the balloon pinned to the target when the browser window is being resized', () => {
+				view.pin( { target, limiter } );
 
 
-			document.body.dispatchEvent( new Event( 'scroll' ) );
+				sinon.assert.calledOnce( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
 
 
-			sinon.assert.calledTwice( attachToSpy );
-		} );
+				window.dispatchEvent( new Event( 'resize' ) );
+
+				sinon.assert.calledTwice( attachToSpy );
+				sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+			} );
+
+			it( 'should stop attaching when the balloon is hidden', () => {
+				view.pin( { target, limiter } );
+
+				sinon.assert.calledOnce( attachToSpy );
+
+				view.hide();
 
 
-		it( 'should work for Range as a target', () => {
-			const element = document.createElement( 'div' );
-			const range = document.createRange();
+				window.dispatchEvent( new Event( 'resize' ) );
+				window.dispatchEvent( new Event( 'scroll' ) );
 
 
-			element.appendChild( document.createTextNode( 'foo bar' ) );
-			document.body.appendChild( element );
-			range.selectNodeContents( element );
+				// Still once.
+				sinon.assert.calledOnce( attachToSpy );
+			} );
+
+			it( 'should stop attaching once the view is destroyed', () => {
+				view.pin( { target, limiter } );
+
+				sinon.assert.calledOnce( attachToSpy );
+
+				return view.destroy().then( () => {
+					view = null;
+
+					window.dispatchEvent( new Event( 'resize' ) );
+					window.dispatchEvent( new Event( 'scroll' ) );
+
+					// Still once.
+					sinon.assert.calledOnce( attachToSpy );
+				} );
+			} );
+
+			it( 'should set document.body as the default limiter', () => {
+				view.pin( { target } );
+
+				sinon.assert.calledOnce( attachToSpy );
+
+				document.body.dispatchEvent( new Event( 'scroll' ) );
+
+				sinon.assert.calledTwice( attachToSpy );
+			} );
 
 
-			view.keepAttachedTo( { target: range } );
+			it( 'should work for Range as a target', () => {
+				const element = document.createElement( 'div' );
+				const range = document.createRange();
 
 
-			sinon.assert.calledOnce( attachToSpy );
+				element.appendChild( document.createTextNode( 'foo bar' ) );
+				document.body.appendChild( element );
+				range.selectNodeContents( element );
 
 
-			element.dispatchEvent( new Event( 'scroll' ) );
+				view.pin( { target: range } );
 
 
-			sinon.assert.calledTwice( attachToSpy );
+				sinon.assert.calledOnce( attachToSpy );
+
+				element.dispatchEvent( new Event( 'scroll' ) );
+
+				sinon.assert.calledTwice( attachToSpy );
+			} );
+
+			it( 'should work for rect as a target', () => {
+				// Just check if this normally works without errors.
+				const rect = {};
+
+				view.pin( { target: rect, limiter } );
+
+				sinon.assert.calledOnce( attachToSpy );
+
+				limiter.dispatchEvent( new Event( 'scroll' ) );
+
+				sinon.assert.calledTwice( attachToSpy );
+			} );
 		} );
 		} );
 
 
-		it( 'should work for rect as a target', () => {
-			// Just check if this normally works without errors.
-			const rect = {};
+		describe( 'unpin()', () => {
+			it( 'should hide the balloon if pinned', () => {
+				const spy = sinon.spy( view, 'hide' );
+
+				view.pin( { target, limiter } );
+				view.unpin();
 
 
-			view.keepAttachedTo( { target: rect, limiter } );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should stop attaching', () => {
+				view.pin( { target, limiter } );
+				sinon.assert.calledOnce( attachToSpy );
 
 
-			sinon.assert.calledOnce( attachToSpy );
+				view.unpin();
 
 
-			limiter.dispatchEvent( new Event( 'scroll' ) );
+				view.hide();
+				window.dispatchEvent( new Event( 'resize' ) );
+				document.dispatchEvent( new Event( 'scroll' ) );
+				view.show();
+				window.dispatchEvent( new Event( 'resize' ) );
+				document.dispatchEvent( new Event( 'scroll' ) );
 
 
-			sinon.assert.calledTwice( attachToSpy );
+				sinon.assert.calledOnce( attachToSpy );
+			} );
 		} );
 		} );
 	} );
 	} );
 } );
 } );

+ 95 - 128
packages/ckeditor5-ui/tests/contextualballoon.js → packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -4,14 +4,13 @@
  */
  */
 
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import ContextualBalloon from '../src/contextualballoon';
-import BalloonPanelView from '../src/panel/balloon/balloonpanelview';
-import View from '../src/view';
-import Template from '../src/template';
+import ContextualBalloon from '../../../src/panel/balloon/contextualballoon';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
+import View from '../../../src/view';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
-/* global document, Event */
+/* global document, Event, setTimeout */
 
 
 describe( 'ContextualBalloon', () => {
 describe( 'ContextualBalloon', () => {
 	let editor, editorElement, balloon, viewA, viewB;
 	let editor, editorElement, balloon, viewA, viewB;
@@ -27,11 +26,20 @@ describe( 'ContextualBalloon', () => {
 			editor = newEditor;
 			editor = newEditor;
 			balloon = editor.plugins.get( ContextualBalloon );
 			balloon = editor.plugins.get( ContextualBalloon );
 
 
-			viewA = new ViewA();
-			viewB = new ViewB();
-
 			// We don't need to test attachTo method of BalloonPanel it's enough to check if was called with proper data.
 			// We don't need to test attachTo method of BalloonPanel it's enough to check if was called with proper data.
 			sinon.stub( balloon.view, 'attachTo', () => {} );
 			sinon.stub( balloon.view, 'attachTo', () => {} );
+
+			viewA = new View();
+			viewB = new View();
+
+			// Add viewA to the pane and init viewB.
+			return Promise.all( [
+				balloon.add( {
+					view: viewA,
+					position: { target: 'fake' }
+				} ),
+				viewB.init(),
+			] );
 		} );
 		} );
 	} );
 	} );
 
 
@@ -74,20 +82,10 @@ describe( 'ContextualBalloon', () => {
 
 
 	describe( 'hasView()', () => {
 	describe( 'hasView()', () => {
 		it( 'should return true when given view is in stack', () => {
 		it( 'should return true when given view is in stack', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			expect( balloon.hasView( viewA ) ).to.true;
 			expect( balloon.hasView( viewA ) ).to.true;
 		} );
 		} );
 
 
 		it( 'should return true when given view is in stack but is not visible', () => {
 		it( 'should return true when given view is in stack but is not visible', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
@@ -98,17 +96,39 @@ describe( 'ContextualBalloon', () => {
 		} );
 		} );
 
 
 		it( 'should return false when given view is not in stack', () => {
 		it( 'should return false when given view is not in stack', () => {
-			expect( balloon.hasView( viewA ) ).to.false;
+			expect( balloon.hasView( viewB ) ).to.false;
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'add()', () => {
 	describe( 'add()', () => {
-		it( 'should add view to the stack and display in balloon', () => {
-			balloon.add( {
-				view: viewA,
+		it( 'should return promise resolved when view is ready', ( done ) => {
+			const clock = sinon.useFakeTimers();
+
+			const view = {
+				init: () => {
+					return new Promise( ( resolve ) => {
+						setTimeout( () => {
+							resolve();
+						}, 10 );
+					} );
+				},
+				destroy: () => {}
+			};
+
+			const result = balloon.add( {
+				view: view,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
 			} );
 			} );
 
 
+			expect( result ).to.instanceof( Promise );
+
+			result.then( done );
+
+			clock.tick( 11 );
+			clock.restore();
+		} );
+
+		it( 'should add view to the stack and display in balloon attached using given position options', () => {
 			expect( balloon.view.content.length ).to.equal( 1 );
 			expect( balloon.view.content.length ).to.equal( 1 );
 			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA );
 			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA );
 			expect( balloon.view.attachTo.calledOnce ).to.true;
 			expect( balloon.view.attachTo.calledOnce ).to.true;
@@ -116,11 +136,6 @@ describe( 'ContextualBalloon', () => {
 		} );
 		} );
 
 
 		it( 'should throw an error when try to add the same view more than once', () => {
 		it( 'should throw an error when try to add the same view more than once', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			expect( () => {
 			expect( () => {
 				balloon.add( {
 				balloon.add( {
 					view: viewA,
 					view: viewA,
@@ -130,11 +145,6 @@ describe( 'ContextualBalloon', () => {
 		} );
 		} );
 
 
 		it( 'should add multiple views to he stack and display last one', () => {
 		it( 'should add multiple views to he stack and display last one', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
@@ -144,47 +154,31 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewB );
 			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewB );
 		} );
 		} );
 
 
-		it( 'should add multiple views to the stack and keep balloon in the same position', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake', foo: 'bar' }
-			} );
-
-			balloon.add( {
+		it( 'should keep balloon at the same position after adding next view', () => {
+			return balloon.add( {
 				view: viewB,
 				view: viewB,
-				position: { target: 'fake', bar: 'biz' }
-			} );
-
-			expect( balloon.view.attachTo.calledTwice ).to.true;
+				position: { target: 'other' }
+			} )
+			.then( () => {
+				expect( balloon.view.attachTo.calledTwice ).to.true;
 
 
-			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
-				target: 'fake',
-				foo: 'bar'
-			} );
+				expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
+					target: 'fake'
+				} );
 
 
-			expect( balloon.view.attachTo.secondCall.args[ 0 ] ).to.deep.equal( {
-				target: 'fake',
-				foo: 'bar'
+				expect( balloon.view.attachTo.secondCall.args[ 0 ] ).to.deep.equal( {
+					target: 'fake'
+				} );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'visibleView', () => {
 	describe( 'visibleView', () => {
 		it( 'should return data of currently visible view', () => {
 		it( 'should return data of currently visible view', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			expect( balloon.visibleView ).to.equal( viewA );
 			expect( balloon.visibleView ).to.equal( viewA );
 		} );
 		} );
 
 
 		it( 'should return data of currently visible view when there is more than one in the stack', () => {
 		it( 'should return data of currently visible view when there is more than one in the stack', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
@@ -194,28 +188,26 @@ describe( 'ContextualBalloon', () => {
 		} );
 		} );
 
 
 		it( 'should return `null` when the stack is empty', () => {
 		it( 'should return `null` when the stack is empty', () => {
+			balloon.remove( viewA );
 			expect( balloon.visibleView ).to.null;
 			expect( balloon.visibleView ).to.null;
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'remove()', () => {
 	describe( 'remove()', () => {
+		it( 'should return promise', () => {
+			expect( balloon.remove( viewA ) ).to.instanceof( Promise );
+		} );
+
 		it( 'should remove given view and hide balloon when there is no other view to display', () => {
 		it( 'should remove given view and hide balloon when there is no other view to display', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
+			balloon.view.isVisible = true;
 
 
 			balloon.remove( viewA );
 			balloon.remove( viewA );
 
 
 			expect( balloon.visibleView ).to.null;
 			expect( balloon.visibleView ).to.null;
+			expect( balloon.view.isVisible ).to.false;
 		} );
 		} );
 
 
-		it( 'should remove given view and set previous in the stack as visible when removed view was visible', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
+		it( 'should remove given view and set preceding in the stack as visible when removed view was visible', () => {
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
@@ -226,12 +218,37 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.visibleView ).to.equal( viewA );
 			expect( balloon.visibleView ).to.equal( viewA );
 		} );
 		} );
 
 
-		it( 'should remove given view from the stack when view is not visible', () => {
+		it( 'should wait for init of preceding view when was is not ready', ( done ) => {
+			const clock = sinon.useFakeTimers();
+
+			const view = {
+				init: () => {
+					return new Promise( ( resolve ) => {
+						setTimeout( () => {
+							resolve();
+						}, 10 );
+					} );
+				},
+				destroy: () => {}
+			};
+
 			balloon.add( {
 			balloon.add( {
-				view: viewA,
+				view: view,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
 			} );
 			} );
 
 
+			balloon.remove( viewB ).then( done );
+
+			clock.tick( 11 );
+			clock.restore();
+		} );
+
+		it( 'should remove given view from the stack when view is not visible', () => {
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: { target: 'fake' }
 				position: { target: 'fake' }
@@ -244,18 +261,13 @@ describe( 'ContextualBalloon', () => {
 
 
 		it( 'should throw an error when there is no given view in the stack', () => {
 		it( 'should throw an error when there is no given view in the stack', () => {
 			expect( () => {
 			expect( () => {
-				balloon.remove( viewA );
+				balloon.remove( viewB );
 			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
 			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'updatePosition()', () => {
 	describe( 'updatePosition()', () => {
 		it( 'should attach balloon to the target using the same position options as currently set', () => {
 		it( 'should attach balloon to the target using the same position options as currently set', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
 			balloon.view.attachTo.reset();
 			balloon.view.attachTo.reset();
 
 
 			balloon.updatePosition();
 			balloon.updatePosition();
@@ -265,19 +277,10 @@ describe( 'ContextualBalloon', () => {
 		} );
 		} );
 
 
 		it( 'should attach balloon to the target using the same position options as currently set when there is more than one view', () => {
 		it( 'should attach balloon to the target using the same position options as currently set when there is more than one view', () => {
-			balloon.add( {
-				view: viewA,
-				position: {
-					target: 'fake',
-					foo: 'bar'
-				}
-			} );
-
 			balloon.add( {
 			balloon.add( {
 				view: viewB,
 				view: viewB,
 				position: {
 				position: {
-					target: 'fake',
-					bar: 'biz'
+					target: 'other'
 				}
 				}
 			} );
 			} );
 
 
@@ -287,59 +290,23 @@ describe( 'ContextualBalloon', () => {
 
 
 			expect( balloon.view.attachTo.calledOnce );
 			expect( balloon.view.attachTo.calledOnce );
 			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
 			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( {
-				target: 'fake',
-				foo: 'bar'
-			} );
-		} );
-
-		it( 'should remove given view from the stack when view is not visible', () => {
-			balloon.add( {
-				view: viewA,
-				position: { target: 'fake' }
-			} );
-
-			balloon.add( {
-				view: viewB,
-				position: { target: 'fake' }
+				target: 'fake'
 			} );
 			} );
-
-			balloon.remove( viewA );
-
-			expect( balloon.visibleView ).to.equal( viewB );
 		} );
 		} );
 
 
 		it( 'should throw an error when there is no given view in the stack', () => {
 		it( 'should throw an error when there is no given view in the stack', () => {
 			expect( () => {
 			expect( () => {
-				balloon.remove( viewA );
+				balloon.remove( viewB );
 			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
 			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
 		} );
 		} );
 	} );
 	} );
 
 
 	describe( 'destroy()', () => {
 	describe( 'destroy()', () => {
-		it( 'should balloon panel remove view from editor body collection', () => {
+		it( 'should remove balloon panel view from editor body collection and clear stack', () => {
 			balloon.destroy();
 			balloon.destroy();
 
 
 			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.equal( -1 );
 			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.equal( -1 );
+			expect( balloon.visibleView ).to.null;
 		} );
 		} );
 	} );
 	} );
 } );
 } );
-
-class ViewA extends View {
-	constructor( locale ) {
-		super( locale );
-
-		this.template = new Template( {
-			tag: 'div'
-		} );
-	}
-}
-
-class ViewB extends View {
-	constructor( locale ) {
-		super( locale );
-
-		this.template = new Template( {
-			tag: 'div'
-		} );
-	}
-}

+ 0 - 229
packages/ckeditor5-ui/tests/panel/floating/floatingpanelview.js

@@ -1,229 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* global document, Event */
-
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import FloatingPanelView from '../../../src/panel/floating/floatingpanelview';
-import View from '../../../src/view';
-import ViewCollection from '../../../src/viewcollection';
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import * as positionUtils from '@ckeditor/ckeditor5-utils/src/dom/position';
-
-testUtils.createSinonSandbox();
-
-describe( 'FloatingPanelView', () => {
-	let locale, view, target;
-
-	beforeEach( () => {
-		locale = {};
-		view = new FloatingPanelView( locale );
-
-		target = global.document.createElement( 'a' );
-
-		global.document.body.appendChild( view.element );
-		global.document.body.appendChild( target );
-
-		view.targetElement = target;
-
-		return view.init();
-	} );
-
-	afterEach( () => {
-		view.element.remove();
-
-		return view.destroy();
-	} );
-
-	describe( 'constructor()', () => {
-		it( 'should inherit from View', () => {
-			expect( view ).to.be.instanceOf( View );
-		} );
-
-		it( 'should set view#locale', () => {
-			expect( view.locale ).to.equal( locale );
-		} );
-
-		it( 'should set #isActive', () => {
-			expect( view.isActive ).to.be.false;
-		} );
-
-		it( 'should set #top', () => {
-			expect( view.top ).to.equal( 0 );
-		} );
-
-		it( 'should set #left', () => {
-			expect( view.left ).to.equal( 0 );
-		} );
-
-		it( 'should set #targetElement', () => {
-			view = new FloatingPanelView( locale );
-
-			expect( view.targetElement ).to.be.null;
-		} );
-
-		it( 'creates view#content collection', () => {
-			expect( view.content ).to.be.instanceOf( ViewCollection );
-		} );
-	} );
-
-	describe( 'template', () => {
-		it( 'should create element from template', () => {
-			expect( view.element.classList.contains( 'ck-floating-panel' ) ).to.be.true;
-		} );
-
-		describe( 'bindings', () => {
-			describe( 'class', () => {
-				it( 'reacts on #isActive', () => {
-					view.isActive = false;
-					expect( view.element.classList.contains( 'ck-floating-panel_active' ) ).to.be.false;
-
-					view.isActive = true;
-					expect( view.element.classList.contains( 'ck-floating-panel_active' ) ).to.be.true;
-				} );
-			} );
-
-			describe( 'style', () => {
-				it( 'reacts on #top', () => {
-					view.top = 30;
-					expect( view.element.style.top ).to.equal( '30px' );
-				} );
-
-				it( 'reacts on #left', () => {
-					view.left = 20;
-					expect( view.element.style.left ).to.equal( '20px' );
-				} );
-			} );
-
-			describe( 'children', () => {
-				it( 'should react on view#content', () => {
-					expect( view.element.childNodes.length ).to.equal( 0 );
-
-					const item = new View();
-					item.element = document.createElement( 'div' );
-					view.content.add( item );
-
-					expect( view.element.childNodes.length ).to.equal( 1 );
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'init()', () => {
-		it( 'calls #_updatePosition on window.scroll', () => {
-			const spy = sinon.spy( view, '_updatePosition' );
-
-			global.window.dispatchEvent( new Event( 'scroll', { bubbles: true } ) );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'calls #_updatePosition on #change:isActive', () => {
-			view.isActive = false;
-
-			const spy = sinon.spy( view, '_updatePosition' );
-
-			view.isActive = true;
-			sinon.assert.calledOnce( spy );
-
-			view.isActive = false;
-			sinon.assert.calledTwice( spy );
-		} );
-	} );
-
-	describe( '_updatePosition()', () => {
-		it( 'does not update when not #isActive', () => {
-			const spy = testUtils.sinon.spy( positionUtils, 'getOptimalPosition' );
-
-			view.isActive = false;
-
-			view._updatePosition();
-			sinon.assert.notCalled( spy );
-
-			view.isActive = true;
-
-			view._updatePosition();
-
-			// Note: #_updatePosition() is called on #change:isActive.
-			sinon.assert.calledTwice( spy );
-		} );
-
-		it( 'uses getOptimalPosition() utility', () => {
-			const { nw, sw, ne, se } = FloatingPanelView.defaultPositions;
-
-			view.isActive = true;
-
-			const spy = testUtils.sinon.stub( positionUtils, 'getOptimalPosition' ).returns( {
-				top: 5,
-				left: 10
-			} );
-
-			view._updatePosition();
-
-			sinon.assert.calledWithExactly( spy, {
-				element: view.element,
-				target: target,
-				positions: [ nw, sw, ne, se ],
-				limiter: global.document.body,
-				fitInViewport: true
-			} );
-
-			expect( view.top ).to.equal( 5 );
-			expect( view.left ).to.equal( 10 );
-		} );
-	} );
-
-	describe( 'defaultPositions', () => {
-		let targetRect, panelRect, defaultPositions;
-
-		beforeEach( () => {
-			defaultPositions = FloatingPanelView.defaultPositions;
-
-			targetRect = {
-				top: 10,
-				width: 100,
-				left: 10,
-				height: 10,
-				bottom: 20
-			};
-
-			panelRect = {
-				width: 20,
-				height: 10
-			};
-		} );
-
-		it( 'should provide "nw" position', () => {
-			expect( defaultPositions.nw( targetRect, panelRect ) ).to.deep.equal( {
-				top: 0,
-				left: 10,
-				name: 'nw'
-			} );
-		} );
-
-		it( 'should provide "sw" position', () => {
-			expect( defaultPositions.sw( targetRect, panelRect ) ).to.deep.equal( {
-				top: 20,
-				left: 10,
-				name: 'sw'
-			} );
-		} );
-
-		it( 'should provide "ne" position', () => {
-			expect( defaultPositions.ne( targetRect, panelRect ) ).to.deep.equal( {
-				top: 0,
-				left: 90,
-				name: 'ne'
-			} );
-		} );
-
-		it( 'should provide "se" position', () => {
-			expect( defaultPositions.se( targetRect, panelRect ) ).to.deep.equal( {
-				top: 20,
-				left: 90,
-				name: 'se'
-			} );
-		} );
-	} );
-} );

+ 28 - 24
packages/ckeditor5-ui/theme/components/panel/balloonpanel.scss

@@ -9,25 +9,15 @@
 
 
 	z-index: ck-z( 'modal' );
 	z-index: ck-z( 'modal' );
 
 
-	&:before,
-	&:after {
-		content: "";
-		position: absolute;
-	}
-
-	&:before {
-		z-index: ck-z();
-	}
-
-	&:after {
-		z-index: ck-z( 'default', 1 );
-	}
+	&.ck-balloon-panel_with-arrow {
+		&:before,
+		&:after {
+			content: "";
+			position: absolute;
+		}
 
 
-	&_arrow_s,
-	&_arrow_se,
-	&_arrow_sw {
 		&:before {
 		&:before {
-			z-index: ck-z( 'default' );
+			z-index: ck-z();
 		}
 		}
 
 
 		&:after {
 		&:after {
@@ -35,15 +25,29 @@
 		}
 		}
 	}
 	}
 
 
-	&_arrow_n,
-	&_arrow_ne,
-	&_arrow_nw {
-		&:before {
-			z-index: ck-z( 'default' );
+	&.ck-balloon-panel_arrow {
+		&_s,
+		&_se,
+		&_sw {
+			&:before {
+				z-index: ck-z( 'default' );
+			}
+
+			&:after {
+				z-index: ck-z( 'default', 1 );
+			}
 		}
 		}
 
 
-		&:after {
-			z-index: ck-z( 'default', 1 );
+		&_n,
+		&_ne,
+		&_nw {
+			&:before {
+				z-index: ck-z( 'default' );
+			}
+
+			&:after {
+				z-index: ck-z( 'default', 1 );
+			}
 		}
 		}
 	}
 	}
 
 

+ 0 - 11
packages/ckeditor5-ui/theme/components/panel/floatingpanel.scss

@@ -1,11 +0,0 @@
-// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
-// For licensing, see LICENSE.md or http://ckeditor.com/license
-
-.ck-floating-panel {
-	position: absolute;
-	display: none;
-
-	&_active {
-		display: block;
-	}
-}

+ 0 - 1
packages/ckeditor5-ui/theme/theme.scss

@@ -14,6 +14,5 @@
 @import 'components/list';
 @import 'components/list';
 @import 'components/label';
 @import 'components/label';
 @import 'components/panel/balloonpanel';
 @import 'components/panel/balloonpanel';
-@import 'components/panel/floatingpanel';
 @import 'components/editor';
 @import 'components/editor';
 @import 'components/toolbar/stickytoolbar';
 @import 'components/toolbar/stickytoolbar';