瀏覽代碼

Merge pull request #188 from ckeditor/t/182

Feature: Introduced `ContextualToolbar` plugin. Closes #182. Closes #187.

Introduced several new positions in `BalloonPanelView#defaultPositions`. Added `className` attribute to the `BalloonPanelView` interface.

BREAKING CHANGE: Default positions of the `BalloonPanelView` have been renamed.

BREAKING CHANGE: Class names controlling the arrow of the panel have been renamed.
Aleksander Nowodzinski 8 年之前
父節點
當前提交
3aec18aabf

+ 118 - 21
packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js

@@ -52,7 +52,7 @@ export default class BalloonPanelView extends View {
 
 		/**
 		 * 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 "arrow_se" position. The class
+		 * to the balloon, i.e. `.ck-balloon-panel_arrow_ne` for "arrow_ne" position. The class
 		 * 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.
 		 *
@@ -64,10 +64,10 @@ export default class BalloonPanelView extends View {
 		 * See {@link #withArrow}.
 		 *
 		 * @observable
-		 * @default 'arrow_se'
-		 * @member {'arrow_se'|'arrow_sw'|'arrow_ne'|'arrow_nw'} #position
+		 * @default 'arrow_ne'
+		 * @member {'arrow_ne'|'arrow_nw'|'arrow_se'|'arrow_sw'} #position
 		 */
-		this.set( 'position', 'arrow_se' );
+		this.set( 'position', 'arrow_ne' );
 
 		/**
 		 * Controls whether the balloon panel is visible or not.
@@ -88,6 +88,14 @@ export default class BalloonPanelView extends View {
 		 */
 		this.set( 'withArrow', true );
 
+		/**
+		 * Additional css class added to the {#element}.
+		 *
+		 * @observable
+		 * @member {String} #className
+		 */
+		this.set( 'className' );
+
 		/**
 		 * Max width of the balloon panel, as in CSS.
 		 *
@@ -118,7 +126,8 @@ export default class BalloonPanelView extends View {
 					'ck-balloon-panel',
 					bind.to( 'position', ( value ) => `ck-balloon-panel_${ value }` ),
 					bind.if( 'isVisible', 'ck-balloon-panel_visible' ),
-					bind.if( 'withArrow', 'ck-balloon-panel_with-arrow' )
+					bind.if( 'withArrow', 'ck-balloon-panel_with-arrow' ),
+					bind.to( 'className' )
 				],
 
 				style: {
@@ -171,10 +180,10 @@ export default class BalloonPanelView extends View {
 		const positionOptions = Object.assign( {}, {
 			element: this.element,
 			positions: [
-				defaultPositions.se,
-				defaultPositions.sw,
-				defaultPositions.ne,
-				defaultPositions.nw
+				defaultPositions.southEastArrowNorthEast,
+				defaultPositions.southWestArrowNorthEast,
+				defaultPositions.northEastArrowSouthWest,
+				defaultPositions.northWestArrowSouthEast
 			],
 			limiter: defaultLimiterElement,
 			fitInViewport: true
@@ -324,7 +333,7 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *
  * The available positioning functions are as follows:
  *
- * * South east:
+ * * South east (arrow north west):
  *
  *		[ Target ]
  *		    ^
@@ -333,7 +342,7 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *		+-----------------+
  *
  *
- * * South west:
+ * * South west (arrow north east):
  *
  *		         [ Target ]
  *		              ^
@@ -342,7 +351,7 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *		+-----------------+
  *
  *
- * * North east:
+ * * North east (arrow south west):
  *
  *		+-----------------+
  *		|     Balloon     |
@@ -351,7 +360,7 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *		[ Target ]
  *
  *
- * * North west:
+ * * North west (arrow south east):
  *
  *		+-----------------+
  *		|     Balloon     |
@@ -359,6 +368,58 @@ BalloonPanelView.arrowVerticalOffset = 15;
  *		              V
  *		         [ Target ]
  *
+ *
+ * * South east (arrow north):
+ *
+ * 		      [ Target ]
+ * 		           ^
+ * 		  +-----------------+
+ * 		  |     Balloon     |
+ * 		  +-----------------+
+ *
+ *
+ * * North east (arrow south):
+ *
+ * 		  +-----------------+
+ * 		  |     Balloon     |
+ * 		  +-----------------+
+ * 		           V
+ * 		      [ Target ]
+ *
+ *
+ * * North west (arrow south):
+ *
+ * 		+-----------------+
+ * 		|     Balloon     |
+ * 		+-----------------+
+ * 		         V
+ * 		    [ Target ]
+ *
+ *
+ * * South west (arrow north):
+ *
+ * 		    [ Target ]
+ * 		         ^
+ * 		+-----------------+
+ * 		|     Balloon     |
+ * 		+-----------------+
+ *
+ * * South (arrow north):
+ *
+ * 		     [ Target ]
+ * 		         ^
+ * 		+-----------------+
+ * 		|     Balloon     |
+ * 		+-----------------+
+ *
+ * * North (arrow south):
+ *
+ * 		+-----------------+
+ * 		|     Balloon     |
+ * 		+-----------------+
+ * 		        V
+ * 		    [ Target ]
+ *
  * See {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView#attachTo}.
  *
  * Positioning functions must be compatible with {@link module:utils/dom/position~Position}.
@@ -369,27 +430,63 @@ BalloonPanelView.arrowVerticalOffset = 15;
  * @member {Object} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions
  */
 BalloonPanelView.defaultPositions = {
-	se: ( targetRect ) => ( {
+	southEastArrowNorthEast: ( targetRect ) => ( {
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
-		name: 'arrow_se'
+		name: 'arrow_ne'
 	} ),
 
-	sw: ( targetRect, balloonRect ) => ( {
+	southWestArrowNorthEast: ( targetRect, balloonRect ) => ( {
 		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
-		name: 'arrow_sw'
+		name: 'arrow_nw'
 	} ),
 
-	ne: ( targetRect, balloonRect ) => ( {
+	northEastArrowSouthWest: ( targetRect, balloonRect ) => ( {
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - BalloonPanelView.arrowHorizontalOffset,
-		name: 'arrow_ne'
+		name: 'arrow_se'
 	} ),
 
-	nw: ( targetRect, balloonRect ) => ( {
+	northWestArrowSouthEast: ( targetRect, balloonRect ) => ( {
 		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
 		left: targetRect.left + targetRect.width / 2 - balloonRect.width + BalloonPanelView.arrowHorizontalOffset,
-		name: 'arrow_nw'
+		name: 'arrow_sw'
+	} ),
+
+	southEastArrowNorth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.right - balloonRect.width / 2,
+		name: 'arrow_n'
+	} ),
+
+	northEastArrowSouth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.right - balloonRect.width / 2,
+		name: 'arrow_s'
+	} ),
+
+	northWestArrowSouth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.left - balloonRect.width / 2,
+		name: 'arrow_s'
+	} ),
+
+	southWestArrowNorth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.left - balloonRect.width / 2,
+		name: 'arrow_n'
+	} ),
+
+	southArrowNorth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.bottom + BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
+		name: 'arrow_n'
+	} ),
+
+	northArrowSouth: ( targetRect, balloonRect ) => ( {
+		top: targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset,
+		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
+		name: 'arrow_s'
 	} )
 };

+ 26 - 13
packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js

@@ -28,6 +28,13 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * @extends module:core/plugin~Plugin
  */
 export default class ContextualBalloon extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ui/contextualballoon';
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -56,10 +63,6 @@ export default class ContextualBalloon extends Plugin {
 		return this.editor.ui.view.body.add( this.view );
 	}
 
-	static get pluginName() {
-		return 'contextualballoon';
-	}
-
 	/**
 	 * Returns the currently visible view or `null` when there are no
 	 * views in the stack.
@@ -88,6 +91,7 @@ export default class ContextualBalloon extends Plugin {
 	 * @param {Object} data Configuration of the view.
 	 * @param {module:ui/view~View} [data.view] Content of the balloon.
 	 * @param {module:utils/dom/position~Options} [data.position] Positioning options.
+	 * @param {String} [data.balloonClassName] Additional css class for {@link #view} added when given view is visible.
 	 * @returns {Promise} A Promise resolved when the child {@link module:ui/view~View#init} is done.
 	 */
 	add( data ) {
@@ -109,7 +113,7 @@ export default class ContextualBalloon extends Plugin {
 		// Add new view to the stack.
 		this._stack.set( data.view, data );
 		// And display it.
-		return this._show( data.view );
+		return this._show( data );
 	}
 
 	/**
@@ -147,7 +151,7 @@ export default class ContextualBalloon extends Plugin {
 			// If it is some other view.
 			if ( last ) {
 				// Just show it.
-				promise = this._show( last.view );
+				promise = this._show( last );
 			} else {
 				// Hide the balloon panel.
 				this.view.hide();
@@ -161,10 +165,16 @@ export default class ContextualBalloon extends Plugin {
 	}
 
 	/**
-	 * Updates the position of the balloon panel according to position data
-	 * of the first view in the stack.
+	 * Updates the position of the balloon panel according to the given position data
+	 * or position data of the first view in the stack.
+	 *
+	 * @param {module:utils/dom/position~Options} [position] position options.
 	 */
-	updatePosition() {
+	updatePosition( position ) {
+		if ( position ) {
+			this._stack.values().next().value.position = position;
+		}
+
 		this.view.attachTo( this._getBalloonPosition() );
 	}
 
@@ -173,10 +183,13 @@ export default class ContextualBalloon extends Plugin {
 	 * options of the first view.
 	 *
 	 * @private
-	 * @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.
+	 * @param {Object} data Configuration.
+	 * @param {module:ui/view~View} [data.view] View to show in the balloon.
+	 * @param {String} [data.balloonClassName=''] Additional class name which will added to the {#_balloon} view.
 	 */
-	_show( view ) {
+	_show( { view, balloonClassName = '' } ) {
+		this.view.className = balloonClassName;
+
 		return this.view.content.add( view ).then( () => {
 			this.view.pin( this._getBalloonPosition() );
 		} );
@@ -190,7 +203,7 @@ export default class ContextualBalloon extends Plugin {
 	 * @returns {module:utils/dom/position~Options}
 	 */
 	_getBalloonPosition() {
-		return Array.from( this._stack.values() )[ 0 ].position;
+		return this._stack.values().next().value.position;
 	}
 
 	/**

+ 220 - 0
packages/ckeditor5-ui/src/toolbar/contextual/contextualtoolbar.js

@@ -0,0 +1,220 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/toolbar/contextual/contextualtoolbar
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ContextualBalloon from '../../panel/balloon/contextualballoon';
+import ToolbarView from '../toolbarview';
+import BalloonPanelView from '../../panel/balloon/balloonpanelview.js';
+import debounce from '@ckeditor/ckeditor5-utils/src/lib/lodash/debounce';
+
+const defaultPositions = BalloonPanelView.defaultPositions;
+
+/**
+ * The contextual toolbar.
+ *
+ * It uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ContextualToolbar extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ui/contextualtoolbar';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		/**
+		 * The toolbar view displayed in the balloon.
+		 *
+		 * @member {module:ui/toolbar/toolbarview~ToolbarView}
+		 */
+		this.toolbarView = new ToolbarView( this.editor.locale );
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = this.editor.plugins.get( ContextualBalloon );
+
+		/**
+		 * Fires {@link #event:_selectionChangeDebounced} event using `lodash#debounce`.
+		 *
+		 * This function is stored as a plugin property to make possible to cancel
+		 * trailing debounced invocation on destroy.
+		 *
+		 * @private
+		 * @member {Function}
+		 */
+		this._fireSelectionChangeDebounced = debounce( () => this.fire( '_selectionChangeDebounced' ), 200 );
+
+		// Attach lifecycle actions.
+		this._handleSelectionChange();
+		this._handleFocusChange();
+	}
+
+	/**
+	 * Creates toolbar components based on given configuration.
+	 * This needs to be done when all plugins will be ready.
+	 *
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const config = this.editor.config.get( 'contextualToolbar' );
+		const factory = this.editor.ui.componentFactory;
+
+		return this.toolbarView.fillFromConfig( config, factory );
+	}
+
+	/**
+	 * Handles editor focus change and hides panel if it's needed.
+	 *
+	 * @private
+	 */
+	_handleFocusChange() {
+		const editor = this.editor;
+
+		// Hide the panel View when editor loses focus but no the other way around.
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, isFocused ) => {
+			if ( this._balloon.visibleView === this.toolbarView && !isFocused ) {
+				this._hidePanel();
+			}
+		} );
+	}
+
+	/**
+	 * Handles {@link module:engine/model/document~Document#selection} change and show or hide toolbar.
+	 *
+	 * Note that in this case it's better to listen to {@link module:engine/model/document~Document model document}
+	 * selection instead of {@link module:engine/view/document~Document view document} selection because the first one
+	 * doesn't fire `change` event after text style change (like bold or italic) and toolbar doesn't blink.
+	 *
+	 * @private
+	 */
+	_handleSelectionChange() {
+		const selection = this.editor.document.selection;
+
+		this.listenTo( selection, 'change:range', ( evt, data ) => {
+			// When the selection is not changed by a collaboration and when is not collapsed.
+			if ( data.directChange || selection.isCollapsed ) {
+				// Hide the toolbar when the selection starts changing.
+				this._hidePanel();
+			}
+
+			// Fire internal `_selectionChangeDebounced` when the selection stops changing.
+			this._fireSelectionChangeDebounced();
+		} );
+
+		// Hide the toolbar when the selection stops changing.
+		this.listenTo( this, '_selectionChangeDebounced', () => this._showPanel() );
+	}
+
+	/**
+	 * Adds panel view to the {@link: #_balloon} and attaches panel to the selection.
+	 *
+	 * @protected
+	 * @return {Promise} A promise resolved when the {@link #toolbarView} {@link module:ui/view~View#init} is done.
+	 */
+	_showPanel() {
+		const editingView = this.editor.editing.view;
+
+		// Do not add toolbar to the balloon stack twice.
+		if ( this._balloon.hasView( this.toolbarView ) ) {
+			return Promise.resolve();
+		}
+
+		// This implementation assumes that only non–collapsed selections gets the contextual toolbar.
+		if ( !editingView.isFocused || editingView.selection.isCollapsed ) {
+			return Promise.resolve();
+		}
+
+		// Update panel position when selection changes while balloon will be opened (by a collaboration).
+		this.listenTo( this.editor.editing.view, 'render', () => {
+			this._balloon.updatePosition( this._getBalloonPositionData() );
+		} );
+
+		// Add panel to the common editor contextual balloon.
+		return this._balloon.add( {
+			view: this.toolbarView,
+			position: this._getBalloonPositionData(),
+			balloonClassName: 'ck-toolbar__container'
+		} );
+	}
+
+	/**
+	 * Removes panel from the {@link: #_balloon}.
+	 *
+	 * @private
+	 */
+	_hidePanel() {
+		if ( this._balloon.hasView( this.toolbarView ) ) {
+			this.stopListening( this.editor.editing.view, 'render' );
+			this._balloon.remove( this.toolbarView );
+		}
+	}
+
+	/**
+	 * Returns positioning options for the {@link #_balloon}. They control the way balloon is attached
+	 * to the selection.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPositionData() {
+		const editingView = this.editor.editing.view;
+
+		// Get direction of the selection.
+		const isBackward = editingView.selection.isBackward;
+
+		// getBoundingClientRect() makes no sense when the selection spans across number
+		// of lines of text. Using getClientRects() allows us to browse micro–ranges
+		// that would normally make up the bounding client rect.
+		const rangeRects = editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ).getClientRects();
+
+		// Select the proper range rect depending on the direction of the selection.
+		const rangeRect = isBackward ? rangeRects.item( 0 ) : rangeRects.item( rangeRects.length - 1 );
+
+		return {
+			target: rangeRect,
+			positions: isBackward ?
+				[ defaultPositions.northWestArrowSouth, defaultPositions.southWestArrowNorth ] :
+				[ defaultPositions.southEastArrowNorth, defaultPositions.northEastArrowSouth ]
+		};
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this._fireSelectionChangeDebounced.cancel();
+		this.stopListening();
+		super.destroy();
+	}
+
+	/**
+	 * This is internal plugin event which is fired 200 ms after model selection last change.
+	 * This is to makes easy test debounced action without need to use `setTimeout`.
+	 *
+	 * @protected
+	 * @event _selectionChangeDebounced
+	 */
+}

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

@@ -16,6 +16,7 @@ import EssentialsPresets from '@ckeditor/ckeditor5-presets/src/essentials';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
 import ContextualBalloon from '../../../src/panel/balloon/contextualballoon';
 import ToolbarView from '../../../src/toolbar/toolbarview';
 import ButtonView from '../../../src/button/buttonview';
@@ -224,21 +225,7 @@ function createContextualToolbar( editor ) {
 	const balloon = editor.plugins.get( ContextualBalloon );
 	const toolbar = new ToolbarView();
 	const editingView = editor.editing.view;
-	const arrowVOffset = 20;
-
-	const positions = {
-		forwardSelection: ( targetRect, balloonRect ) => ( {
-			top: targetRect.bottom + arrowVOffset,
-			left: targetRect.right - balloonRect.width / 2,
-			name: 'arrow_s'
-		} ),
-
-		backwardSelection: ( targetRect, balloonRect ) => ( {
-			top: targetRect.top - balloonRect.height - arrowVOffset,
-			left: targetRect.left - balloonRect.width / 2,
-			name: 'arrow_n'
-		} )
-	};
+	const defaultPositions = BalloonPanelView.defaultPositions;
 
 	// Add plugins to the toolbar.
 	toolbar.fillFromConfig( [ 'PluginA', 'PluginB' ], editor.ui.componentFactory );
@@ -257,7 +244,7 @@ function createContextualToolbar( editor ) {
 				view: toolbar,
 				position: {
 					target: isBackward ? rangeRects.item( 0 ) : rangeRects.item( rangeRects.length - 1 ),
-					positions: [ positions[ isBackward ? 'backwardSelection' : 'forwardSelection' ] ]
+					positions: [ defaultPositions[ isBackward ? 'northArrowSouth' : 'southArrowNorth' ] ]
 				}
 			} );
 		}

+ 5 - 112
packages/ckeditor5-ui/tests/manual/contextualtoolbar/contextualtoolbar.js

@@ -6,124 +6,17 @@
 /* globals window, document, console:false */
 
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
-import DomEventObserver from '@ckeditor/ckeditor5-engine/src/view/observer/domeventobserver';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
-
-import Template from '../../../src/template';
-import ToolbarView from '../../../src/toolbar/toolbarview';
-import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
-
-const arrowVOffset = BalloonPanelView.arrowVerticalOffset;
-const positions = {
-	//     [text range]
-	//                ^
-	//       +-----------------+
-	//       |     Balloon     |
-	//       +-----------------+
-	forwardSelection: ( targetRect, balloonRect ) => ( {
-		top: targetRect.bottom + arrowVOffset,
-		left: targetRect.right - balloonRect.width / 2,
-		name: 'arrow_s'
-	} ),
-
-	//	+-----------------+
-	//	|     Balloon     |
-	//	+-----------------+
-	//	        V
-	//	        [text range]
-	backwardSelection: ( targetRect, balloonRect ) => ( {
-		top: targetRect.top - balloonRect.height - arrowVOffset,
-		left: targetRect.left - balloonRect.width / 2,
-		name: 'arrow_n'
-	} )
-};
+import ArticlePresets from '@ckeditor/ckeditor5-presets/src/article';
+import ContextualToolbar from '../../../src/toolbar/contextual/contextualtoolbar';
 
 ClassicEditor.create( document.querySelector( '#editor' ), {
-	plugins: [ Enter, Typing, Paragraph, Undo, Bold, Italic ],
-	toolbar: [ 'bold', 'italic', 'undo', 'redo' ]
+	plugins: [ ArticlePresets, ContextualToolbar ],
+	toolbar: [ 'bold', 'italic', 'link', 'undo', 'redo' ],
+	contextualToolbar: [ 'bold', 'italic', 'link' ]
 } )
 .then( editor => {
-	createContextualToolbar( editor );
 	window.editor = editor;
 } )
 .catch( err => {
 	console.error( err.stack );
 } );
-
-function createContextualToolbar( editor ) {
-	// Create a plain toolbar instance.
-	const toolbar = new ToolbarView();
-
-	// Create a BalloonPanelView instance.
-	const panel = new BalloonPanelView( editor.locale );
-
-	Template.extend( panel.template, {
-		attributes: {
-			class: [
-				'ck-toolbar__container',
-			]
-		}
-	} );
-
-	// Putting the toolbar inside of the balloon panel.
-	panel.content.add( toolbar );
-
-	editor.ui.view.body.add( panel ).then( () => {
-		const editingView = editor.editing.view;
-
-		// Fill the toolbar with some buttons. Simply copy default editor toolbar.
-		toolbar.fillFromConfig( editor.config.get( 'toolbar' ), editor.ui.componentFactory );
-
-		// Let the focusTracker know about new focusable UI element.
-		editor.ui.focusTracker.add( panel.element );
-
-		// Hide the panel when editor loses focus but no the other way around.
-		panel.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, is, was ) => {
-			if ( was && !is ) {
-				panel.hide();
-			}
-		} );
-
-		// Add "mouseup" event observer. It's enought to use ClickObserver in Chrome
-		// but Firefox requires "mouseup" to work properly.
-		editingView.addObserver( class extends DomEventObserver {
-			get domEventType() {
-				return [ 'mouseup' ];
-			}
-
-			onDomEvent( domEvent ) {
-				this.fire( domEvent.type, domEvent );
-			}
-		} );
-
-		// Position the panel each time the user clicked in editable.
-		editor.listenTo( editingView, 'mouseup', () => {
-			// This implementation assumes that only non–collapsed selections gets the contextual toolbar.
-			if ( !editingView.selection.isCollapsed ) {
-				const isBackward = editingView.selection.isBackward;
-
-				// getBoundingClientRect() makes no sense when the selection spans across number
-				// of lines of text. Using getClientRects() allows us to browse micro–ranges
-				// that would normally make up the bounding client rect.
-				const rangeRects = editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ).getClientRects();
-
-				// Select the proper range rect depending on the direction of the selection.
-				const rangeRect = isBackward ? rangeRects.item( 0 ) : rangeRects.item( rangeRects.length - 1 );
-
-				panel.attachTo( {
-					target: rangeRect,
-					positions: [
-						positions[ isBackward ? 'backwardSelection' : 'forwardSelection' ]
-					]
-				} );
-			} else {
-				panel.hide();
-			}
-		} );
-	} );
-}

+ 3 - 26
packages/ckeditor5-ui/tests/manual/imagetoolbar/imagetoolbar.js

@@ -19,31 +19,6 @@ import Template from '../../../src/template';
 import ToolbarView from '../../../src/toolbar/toolbarview';
 import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
 
-const arrowVOffset = BalloonPanelView.arrowVerticalOffset;
-const positions = {
-	//          [text range]
-	//                ^
-	//       +-----------------+
-	//       |     Balloon     |
-	//       +-----------------+
-	south: ( targetRect, balloonRect ) => ( {
-		top: targetRect.bottom + arrowVOffset,
-		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
-		name: 's'
-	} ),
-
-	//	+-----------------+
-	//	|     Balloon     |
-	//	+-----------------+
-	//	        V
-	//	   [text range]
-	north: ( targetRect, balloonRect ) => ( {
-		top: targetRect.top - balloonRect.height - arrowVOffset,
-		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
-		name: 'n'
-	} )
-};
-
 ClassicEditor.create( document.querySelector( '#editor' ), {
 	plugins: [ Enter, Typing, Paragraph, Undo, Bold, Italic, Image ],
 	toolbar: [ 'bold', 'italic', 'undo', 'redo' ]
@@ -111,9 +86,11 @@ function createImageToolbar( editor ) {
 		} );
 
 		function attachToolbar() {
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
 			panel.attachTo( {
 				target: editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ),
-				positions: [ positions.north, positions.south ]
+				positions: [ defaultPositions.northArrowSouth, defaultPositions.southArrowNorth ]
 			} );
 		}
 	} );

+ 135 - 27
packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js

@@ -41,7 +41,7 @@ describe( 'BalloonPanelView', () => {
 		it( 'should set default values', () => {
 			expect( view.top ).to.equal( 0 );
 			expect( view.left ).to.equal( 0 );
-			expect( view.position ).to.equal( 'arrow_se' );
+			expect( view.position ).to.equal( 'arrow_ne' );
 			expect( view.isVisible ).to.equal( false );
 			expect( view.withArrow ).to.equal( true );
 		} );
@@ -54,11 +54,11 @@ describe( 'BalloonPanelView', () => {
 	describe( 'DOM bindings', () => {
 		describe( 'arrow', () => {
 			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_ne' ) ).to.true;
 
-				view.position = 'arrow_sw';
+				view.position = 'arrow_nw';
 
-				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_sw' ) ).to.true;
+				expect( view.element.classList.contains( 'ck-balloon-panel_arrow_nw' ) ).to.true;
 			} );
 
 			it( 'should react on view#withArrow', () => {
@@ -106,6 +106,18 @@ describe( 'BalloonPanelView', () => {
 			} );
 		} );
 
+		describe( 'className', () => {
+			it( 'should set additional class to the view#element', () => {
+				view.className = 'foo';
+
+				expect( view.element.classList.contains( 'foo' ) ).to.true;
+
+				view.className = '';
+
+				expect( view.element.classList.contains( 'foo' ) ).to.false;
+			} );
+		} );
+
 		describe( 'children', () => {
 			it( 'should react on view#content', () => {
 				expect( view.element.childNodes.length ).to.equal( 0 );
@@ -119,16 +131,6 @@ describe( 'BalloonPanelView', () => {
 		} );
 	} );
 
-	describe( 'isVisible', () => {
-		it( 'should return view#isVisible value', () => {
-			expect( view.isVisible ).to.false;
-
-			view.isVisible = true;
-
-			expect( view.isVisible ).to.true;
-		} );
-	} );
-
 	describe( 'show()', () => {
 		it( 'should set view#isVisible as true', () => {
 			view.isVisible = false;
@@ -187,10 +189,10 @@ describe( 'BalloonPanelView', () => {
 				element: view.element,
 				target: target,
 				positions: [
-					BalloonPanelView.defaultPositions.se,
-					BalloonPanelView.defaultPositions.sw,
-					BalloonPanelView.defaultPositions.ne,
-					BalloonPanelView.defaultPositions.nw
+					BalloonPanelView.defaultPositions.southEastArrowNorthEast,
+					BalloonPanelView.defaultPositions.southWestArrowNorthEast,
+					BalloonPanelView.defaultPositions.northEastArrowSouthWest,
+					BalloonPanelView.defaultPositions.northWestArrowSouthEast
 				],
 				limiter: document.body,
 				fitInViewport: true
@@ -219,7 +221,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_se' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 
 			it( 'should put balloon on the `south east` side of the target element when target is on the top left side of the limiter', () => {
@@ -232,7 +234,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_se' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 
 			it( 'should put balloon on the `south west` side of the target element when target is on the right side of the limiter', () => {
@@ -245,7 +247,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_sw' );
+				expect( view.position ).to.equal( 'arrow_nw' );
 			} );
 
 			it( 'should put balloon on the `north east` side of the target element when target is on the bottom of the limiter ', () => {
@@ -258,7 +260,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_ne' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 
 			it( 'should put balloon on the `north west` side of the target element when target is on the bottom right of the limiter', () => {
@@ -271,7 +273,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_nw' );
+				expect( view.position ).to.equal( 'arrow_sw' );
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-ui-default/issues/126
@@ -352,7 +354,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_sw' );
+				expect( view.position ).to.equal( 'arrow_nw' );
 			} );
 
 			it( 'should put balloon on the `south east` position when `south west` is limited', () => {
@@ -372,7 +374,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_se' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 
 			it( 'should put balloon on the `north east` position when `south east` is limited', () => {
@@ -396,7 +398,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_ne' );
+				expect( view.position ).to.equal( 'arrow_se' );
 			} );
 
 			it( 'should put balloon on the `south east` position when `north east` is limited', () => {
@@ -416,7 +418,7 @@ describe( 'BalloonPanelView', () => {
 
 				view.attachTo( { target, limiter } );
 
-				expect( view.position ).to.equal( 'arrow_se' );
+				expect( view.position ).to.equal( 'arrow_ne' );
 			} );
 		} );
 	} );
@@ -630,6 +632,112 @@ describe( 'BalloonPanelView', () => {
 			} );
 		} );
 	} );
+
+	describe( 'defaultPositions', () => {
+		let positions, balloonRect, targetRect;
+
+		beforeEach( () => {
+			positions = BalloonPanelView.defaultPositions;
+
+			targetRect = {
+				top: 100,
+				bottom: 200,
+				left: 100,
+				right: 200,
+				width: 100,
+				height: 100
+			};
+
+			balloonRect = {
+				top: 0,
+				bottom: 0,
+				left: 0,
+				right: 0,
+				width: 50,
+				height: 50
+			};
+		} );
+
+		it( 'southEastArrowNorthEast', () => {
+			expect( positions.southEastArrowNorthEast( targetRect ) ).to.deep.equal( {
+				top: 215,
+				left: 120,
+				name: 'arrow_ne'
+			} );
+		} );
+
+		it( 'southWestArrowNorthEast', () => {
+			expect( positions.southWestArrowNorthEast( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 215,
+				left: 130,
+				name: 'arrow_nw'
+			} );
+		} );
+
+		it( 'northEastArrowSouthWest', () => {
+			expect( positions.northEastArrowSouthWest( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 35,
+				left: 120,
+				name: 'arrow_se'
+			} );
+		} );
+
+		it( 'northWestArrowSouthEast', () => {
+			expect( positions.northWestArrowSouthEast( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 35,
+				left: 130,
+				name: 'arrow_sw'
+			} );
+		} );
+
+		it( 'southEastArrowNorth', () => {
+			expect( positions.southEastArrowNorth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 215,
+				left: 175,
+				name: 'arrow_n'
+			} );
+		} );
+
+		it( 'northEastArrowSouth', () => {
+			expect( positions.northEastArrowSouth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 35,
+				left: 175,
+				name: 'arrow_s'
+			} );
+		} );
+
+		it( 'northWestArrowSouth', () => {
+			expect( positions.northWestArrowSouth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 35,
+				left: 75,
+				name: 'arrow_s'
+			} );
+		} );
+
+		it( 'southWestArrowNorth', () => {
+			expect( positions.southWestArrowNorth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 215,
+				left: 75,
+				name: 'arrow_n'
+			} );
+		} );
+
+		it( 'southArrowNorth', () => {
+			expect( positions.southArrowNorth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 215,
+				left: 125,
+				name: 'arrow_n'
+			} );
+		} );
+
+		it( 'northArrowSouth', () => {
+			expect( positions.northArrowSouth( targetRect, balloonRect ) ).to.deep.equal( {
+				top: 35,
+				left: 125,
+				name: 'arrow_s'
+			} );
+		} );
+	} );
 } );
 
 function mockBoundingBox( element, data ) {

+ 61 - 7
packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -26,7 +26,8 @@ describe( 'ContextualBalloon', () => {
 			editor = newEditor;
 			balloon = editor.plugins.get( ContextualBalloon );
 
-			// We don't need to test BalloonPanelView attachTo and pin methods it's enough to check if was called with proper data.
+			// We don't need to execute BalloonPanel pin and attachTo methods
+			// it's enough to check if was called with the proper data.
 			sinon.stub( balloon.view, 'attachTo', () => {} );
 			sinon.stub( balloon.view, 'pin', () => {} );
 
@@ -48,13 +49,14 @@ describe( 'ContextualBalloon', () => {
 		editor.destroy();
 	} );
 
-	it( 'should be a plugin instance', () => {
+	it( 'should create a plugin instance', () => {
 		expect( balloon ).to.instanceof( Plugin );
+		expect( balloon ).to.instanceof( ContextualBalloon );
 	} );
 
 	describe( 'pluginName', () => {
 		it( 'should return plugin by name', () => {
-			expect( editor.plugins.get( 'contextualballoon' ) ).to.instanceof( ContextualBalloon );
+			expect( editor.plugins.get( 'ui/contextualballoon' ) ).to.equal( balloon );
 		} );
 	} );
 
@@ -176,6 +178,26 @@ describe( 'ContextualBalloon', () => {
 				} );
 			} );
 		} );
+
+		it( 'should set additional css class of visible view to BalloonPanelView', () => {
+			const view = new View();
+
+			balloon.add( {
+				view: view,
+				position: { target: 'fake' },
+				balloonClassName: 'foo'
+			} );
+
+			expect( balloon.view.className ).to.equal( 'foo' );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' },
+				balloonClassName: 'bar'
+			} );
+
+			expect( balloon.view.className ).to.equal( 'bar' );
+		} );
 	} );
 
 	describe( 'visibleView', () => {
@@ -269,10 +291,30 @@ describe( 'ContextualBalloon', () => {
 				balloon.remove( viewB );
 			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
 		} );
+
+		it( 'should set additional css class of visible view to BalloonPanelView', () => {
+			const view = new View();
+
+			balloon.add( {
+				view: view,
+				position: { target: 'fake' },
+				balloonClassName: 'foo'
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' },
+				balloonClassName: 'bar'
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.view.className ).to.equal( 'foo' );
+		} );
 	} );
 
 	describe( 'updatePosition()', () => {
-		it( 'should attach balloon to the target using the same position options as first view in the stack', () => {
+		it( 'should attach balloon to the target using position option from the first view in the stack', () => {
 			balloon.add( {
 				view: viewB,
 				position: {
@@ -285,9 +327,21 @@ 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' } );
+		} );
+
+		it( 'should attach balloon to the target using new position options', () => {
+			balloon.view.attachTo.reset();
+
+			balloon.updatePosition( { target: 'new' } );
+
+			expect( balloon.view.attachTo.calledOnce );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'new' } );
+
+			balloon.updatePosition();
+
+			expect( balloon.view.attachTo.calledTwice );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'new' } );
 		} );
 
 		it( 'should throw an error when there is no given view in the stack', () => {

+ 409 - 0
packages/ckeditor5-ui/tests/toolbar/contextual/contextualtoolbar.js

@@ -0,0 +1,409 @@
+/**
+ * Copyright (c) 2016, CKSource - Frederico Knabben. All rights reserved.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import ContextualToolbar from '../../../src/toolbar/contextual/contextualtoolbar';
+import ContextualBalloon from '../../../src/panel/balloon/contextualballoon';
+import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
+import ToolbarView from '../../../src/toolbar/toolbarview';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js';
+
+/* global document, setTimeout */
+
+describe( 'ContextualToolbar', () => {
+	let sandbox, editor, contextualToolbar, balloon, editorElement;
+
+	beforeEach( () => {
+		sandbox = sinon.sandbox.create();
+
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ Paragraph, Bold, Italic, ContextualToolbar ],
+			contextualToolbar: [ 'bold', 'italic' ]
+		} )
+		.then( newEditor => {
+			newEditor.editing.view.attachDomRoot( editorElement );
+
+			editor = newEditor;
+			contextualToolbar = editor.plugins.get( ContextualToolbar );
+			balloon = editor.plugins.get( ContextualBalloon );
+
+			// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
+			sandbox.stub( balloon.view, 'attachTo', () => {} );
+			sandbox.stub( balloon.view, 'pin', () => {} );
+
+			// Focus the engine.
+			editor.editing.view.isFocused = true;
+
+			return contextualToolbar.toolbarView.init();
+		} );
+	} );
+
+	afterEach( () => {
+		sandbox.restore();
+
+		return editor.destroy();
+	} );
+
+	it( 'should create a plugin instance', () => {
+		expect( contextualToolbar ).to.instanceOf( Plugin );
+		expect( contextualToolbar ).to.instanceOf( ContextualToolbar );
+		expect( contextualToolbar.toolbarView ).to.instanceof( ToolbarView );
+	} );
+
+	it( 'should load ContextualBalloon', () => {
+		expect( balloon ).to.instanceof( ContextualBalloon );
+	} );
+
+	it( 'should create components from config', () => {
+		expect( contextualToolbar.toolbarView.items ).to.length( 2 );
+	} );
+
+	it( 'should fire internal `_selectionChangeDebounced` event 200 ms after last selection change', ( done ) => {
+		// This test uses setTimeout to test lodash#debounce because sinon fake timers
+		// doesn't work with lodash. Lodash keeps time related stuff in a closure
+		// and sinon is not able to override it.
+
+		const spy = sandbox.spy();
+		setData( editor.document, '<paragraph>[bar]</paragraph>' );
+		contextualToolbar.on( '_selectionChangeDebounced', spy );
+
+		editor.document.selection.fire( 'change:range', {} );
+
+		// Not yet.
+		sinon.assert.notCalled( spy );
+
+		// Lets wait 100 ms.
+		setTimeout( () => {
+			// Still not yet.
+			sinon.assert.notCalled( spy );
+
+			// Fire event one more time.
+			editor.document.selection.fire( 'change:range', {} );
+
+			// Another 100 ms waiting.
+			setTimeout( () => {
+				// Still not yet.
+				sinon.assert.notCalled( spy );
+
+				// Another 101 ms waiting.
+				setTimeout( () => {
+					// And here it is.
+					sinon.assert.calledOnce( spy );
+					done();
+				}, 100 );
+			}, 101 );
+		}, 100 );
+	} );
+
+	describe( 'pluginName', () => {
+		it( 'should return plugin by its name', () => {
+			expect( editor.plugins.get( 'ui/contextualtoolbar' ) ).to.equal( contextualToolbar );
+		} );
+	} );
+
+	describe( '_showPanel()', () => {
+		let balloonAddSpy, forwardSelectionRect, backwardSelectionRect;
+
+		beforeEach( () => {
+			forwardSelectionRect = {
+				top: 100,
+				height: 10,
+				bottom: 110,
+				left: 200,
+				width: 50,
+				right: 250
+			};
+
+			backwardSelectionRect = {
+				top: 100,
+				height: 10,
+				bottom: 110,
+				left: 200,
+				width: 50,
+				right: 250
+			};
+
+			stubSelectionRect( forwardSelectionRect, backwardSelectionRect );
+
+			balloonAddSpy = sandbox.spy( balloon, 'add' );
+			editor.editing.view.isFocused = true;
+		} );
+
+		it( 'should return promise', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			const returned = contextualToolbar._showPanel();
+
+			expect( returned ).to.instanceof( Promise );
+
+			return returned;
+		} );
+
+		it( 'should add #toolbarView to the #_balloon and attach the #_balloon to the selection for the forward selection', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
+			return contextualToolbar._showPanel().then( () => {
+				sinon.assert.calledWithExactly( balloonAddSpy, {
+					view: contextualToolbar.toolbarView,
+					balloonClassName: 'ck-toolbar__container',
+					position: {
+						target: forwardSelectionRect,
+						positions: [ defaultPositions.southEastArrowNorth, defaultPositions.northEastArrowSouth ]
+					}
+				} );
+			} );
+		} );
+
+		it( 'should add #toolbarView to the #_balloon and attach the #_balloon to the selection for the backward selection', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>', { lastRangeBackward: true } );
+
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					sinon.assert.calledWithExactly( balloonAddSpy, {
+						view: contextualToolbar.toolbarView,
+						balloonClassName: 'ck-toolbar__container',
+						position: {
+							target: backwardSelectionRect,
+							positions: [ defaultPositions.northWestArrowSouth, defaultPositions.southWestArrowNorth ]
+						}
+					} );
+				} );
+		} );
+
+		it( 'should update balloon position on ViewDocument#render event while balloon is added to the #_balloon', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			const spy = sandbox.spy( balloon, 'updatePosition' );
+
+			editor.editing.view.fire( 'render' );
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					sinon.assert.notCalled( spy );
+
+					editor.editing.view.fire( 'render' );
+
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
+
+		it( 'should not add #toolbarView to the #_balloon more than once', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			return contextualToolbar._showPanel()
+				.then( () => contextualToolbar._showPanel() )
+				.then( () => {
+					sinon.assert.calledOnce( balloonAddSpy );
+				} );
+		} );
+
+		it( 'should not add #toolbarView to the #_balloon when editor is not focused', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+			editor.editing.view.isFocused = false;
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					sinon.assert.notCalled( balloonAddSpy );
+				} );
+		} );
+
+		it( 'should not add #toolbarView to the #_balloon when selection is collapsed', () => {
+			setData( editor.document, '<paragraph>b[]ar</paragraph>' );
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					sinon.assert.notCalled( balloonAddSpy );
+				} );
+		} );
+	} );
+
+	describe( '_hidePanel()', () => {
+		let removeBalloonSpy;
+
+		beforeEach( () => {
+			removeBalloonSpy = sandbox.spy( balloon, 'remove' );
+			editor.editing.view.isFocused = true;
+		} );
+
+		it( 'should remove #toolbarView from the #_balloon', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					contextualToolbar._hidePanel();
+
+					sinon.assert.calledWithExactly( removeBalloonSpy, contextualToolbar.toolbarView );
+				} );
+		} );
+
+		it( 'should stop update balloon position on ViewDocument#render event', () => {
+			setData( editor.document, '<paragraph>b[a]r</paragraph>' );
+
+			const spy = sandbox.spy( balloon, 'updatePosition' );
+
+			return contextualToolbar._showPanel()
+				.then( () => {
+					contextualToolbar._hidePanel();
+
+					editor.editing.view.fire( 'render' );
+
+					sinon.assert.notCalled( spy );
+				} );
+		} );
+
+		it( 'should not remove #ttolbarView when is not added to the #_balloon', () => {
+			contextualToolbar._hidePanel();
+
+			sinon.assert.notCalled( removeBalloonSpy );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should not fire `_selectionChangeDebounced` after plugin destroy', ( done ) => {
+			const spy = sandbox.spy();
+
+			contextualToolbar.on( '_selectionChangeDebounced', spy );
+
+			editor.document.selection.fire( 'change:range', { directChange: true } );
+
+			contextualToolbar.destroy();
+
+			setTimeout( () => {
+				sinon.assert.notCalled( spy );
+				done();
+			}, 200 );
+		} );
+	} );
+
+	describe( 'showing and hiding', () => {
+		let showPanelSpy, hidePanelSpy;
+
+		beforeEach( () => {
+			setData( editor.document, '<paragraph>[bar]</paragraph>' );
+
+			// Methods are stubbed because return internal promise which can't be returned in test.
+			showPanelSpy = sandbox.stub( contextualToolbar, '_showPanel', () => {} );
+			hidePanelSpy = sandbox.stub( contextualToolbar, '_hidePanel', () => {} );
+		} );
+
+		it( 'should open when selection stops changing', () => {
+			sinon.assert.notCalled( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+		} );
+
+		it( 'should close when selection starts changing by a directChange', () => {
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			editor.document.selection.fire( 'change:range', { directChange: true } );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.calledOnce( hidePanelSpy );
+		} );
+
+		it( 'should not close when selection starts changing by not a directChange', () => {
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			editor.document.selection.fire( 'change:range', { directChange: false } );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+		} );
+
+		it( 'should close when selection starts changing by not a directChange but will become collapsed', () => {
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			// Collapse range silently (without firing `change:range` { directChange: true } event).
+			const range = editor.document.selection._ranges[ 0 ];
+			range.end = range.start;
+
+			editor.document.selection.fire( 'change:range', { directChange: false } );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.calledOnce( hidePanelSpy );
+		} );
+
+		it( 'should hide if the editor loses focus', () => {
+			editor.ui.focusTracker.isFocused = true;
+
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			// Stubbing getters doesn't wor for sandbox.
+			const stub = sinon.stub( balloon, 'visibleView', { get: () => contextualToolbar.toolbarView } );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			editor.ui.focusTracker.isFocused = false;
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.calledOnce( hidePanelSpy );
+
+			stub.restore();
+		} );
+
+		it( 'should not hide if the editor loses focus and #toolbarView is not visible', () => {
+			editor.ui.focusTracker.isFocused = true;
+
+			contextualToolbar.fire( '_selectionChangeDebounced' );
+
+			// Stubbing getters doesn't wor for sandbox.
+			const stub = sinon.stub( balloon, 'visibleView', { get: () => null } );
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			editor.ui.focusTracker.isFocused = false;
+
+			sinon.assert.calledOnce( showPanelSpy );
+			sinon.assert.notCalled( hidePanelSpy );
+
+			stub.restore();
+		} );
+	} );
+
+	function stubSelectionRect( forwardSelectionRect, backwardSelectionRect ) {
+		const editingView = editor.editing.view;
+		const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
+
+		// Mock selection rect.
+		sandbox.stub( editingView.domConverter, 'viewRangeToDom', ( ...args ) => {
+			const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
+
+			sandbox.stub( domRange, 'getClientRects', () => {
+				return {
+					length: 2,
+					item: id => id === 0 ? forwardSelectionRect : backwardSelectionRect
+				};
+			} );
+
+			return domRange;
+		} );
+	}
+} );

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

@@ -26,9 +26,9 @@
 	}
 
 	&.ck-balloon-panel_arrow {
-		&_s,
-		&_se,
-		&_sw {
+		&_n,
+		&_ne,
+		&_nw {
 			&:before {
 				z-index: ck-z( 'default' );
 			}
@@ -38,9 +38,9 @@
 			}
 		}
 
-		&_n,
-		&_ne,
-		&_nw {
+		&_s,
+		&_se,
+		&_sw {
 			&:before {
 				z-index: ck-z( 'default' );
 			}