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

Feature: Introduced ContextualBalloon plugin for managing contextual balloons. Closes #134.

Oskar Wróbel 8 жил өмнө
parent
commit
9637a5cf7e

+ 192 - 0
packages/ckeditor5-ui/src/contextualballoon.js

@@ -0,0 +1,192 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/contextualballoon
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import BalloonPanelView from './panel/balloon/balloonpanelview';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Provides the common contextual balloon panel for the editor.
+ *
+ * This plugin allows reusing a single {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance
+ * to display multiple contextual balloon panels in the editor.
+ *
+ * Child views of such a panel are stored in the stack and the last one in the stack is visible. When the
+ * visible view is removed from the stack, the previous view becomes visible, etc. If there are no more
+ * views in the stack, the balloon panel will hide.
+ *
+ * It simplifies managing the views and helps
+ * avoid the unnecessary complexity of handling multiple {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+ * instances in the editor.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ContextualBalloon extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		/**
+		 * The common balloon panel view.
+		 *
+		 * @readonly
+		 * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view
+		 */
+		this.view = new BalloonPanelView();
+
+		/**
+		 * 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}.
+		 *
+		 * @private
+		 * @member {Map} #_stack
+		 */
+		this._stack = new Map();
+
+		// Add balloon panel view to editor `body` collection.
+		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.
+	 *
+	 * @returns {module:ui/view~View|null}
+	 */
+	get visibleView() {
+		const item = this._stack.get( this.view.content.get( 0 ) );
+
+		return item ? item.view : null;
+	}
+
+	/**
+	 * Returns `true` when the given view is in the stack. Otherwise returns `false`.
+	 *
+	 * @param {module:ui/view~View} view
+	 * @returns {Boolean}
+	 */
+	hasView( view ) {
+		return this._stack.has( view );
+	}
+
+	/**
+	 * Adds a new view to the stack and makes it visible.
+	 *
+	 * @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.
+	 */
+	add( data ) {
+		if ( this.hasView( data.view ) ) {
+			/**
+			 * Trying to add configuration of the same view more than once.
+			 *
+			 * @error contextualballoon-add-view-exist
+			 */
+			throw new CKEditorError( 'contextualballoon-add-view-exist: Cannot add configuration of the same view twice.' );
+		}
+
+		// When adding view to the not empty balloon.
+		if ( this.visibleView ) {
+			// Remove displayed content from the view.
+			this.view.content.remove( this.visibleView );
+		}
+
+		// Add new view to the stack.
+		this._stack.set( data.view, data );
+		// And display it.
+		this._show( data.view );
+	}
+
+	/**
+	 * Removes the given view from the stack. If the removed view was visible,
+	 * then the view preceding it in the stack will become visible instead.
+	 * 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.
+	 */
+	remove( view ) {
+		if ( !this.hasView( view ) ) {
+			/**
+			 * Trying to remove configuration of the view not defined in the stack.
+			 *
+			 * @error contextualballoon-remove-view-not-exist
+			 */
+			throw new CKEditorError( 'contextualballoon-remove-view-not-exist: Cannot remove configuration of not existing view.' );
+		}
+
+		// When visible view is being removed.
+		if ( this.visibleView === view ) {
+			// We need to remove it from the view content.
+			this.view.content.remove( view );
+
+			// And then remove from the stack.
+			this._stack.delete( view );
+
+			// Next we need to check if there is other view in stack to show.
+			const last = Array.from( this._stack.values() ).pop();
+
+			// If it is some other view.
+			if ( last ) {
+				// Just show it.
+				this._show( last.view );
+			} else {
+				// Hide the balloon panel.
+				this.view.hide();
+			}
+		} else {
+			// Just remove given view from the stack.
+			this._stack.delete( view );
+		}
+	}
+
+	/**
+	 * Updates the position of the balloon panel according to position data
+	 * of the first view in the stack.
+	 */
+	updatePosition() {
+		this.view.attachTo( this._getBalloonPosition() );
+	}
+
+	/**
+	 * Sets the view as a content of the balloon and attaches balloon using position
+	 * options of the first view.
+	 *
+	 * @private
+	 * @param {module:ui/view~View} view View to show in the balloon.
+	 */
+	_show( view ) {
+		this.view.content.add( view );
+		this.view.attachTo( this._getBalloonPosition() );
+	}
+
+	/**
+	 * Returns position options of the first view in the stack.
+	 * This keeps the balloon in the same position when view is changed.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPosition() {
+		return Array.from( this._stack.values() )[ 0 ].position;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this.editor.ui.view.body.remove( this.view );
+		this.view.destroy();
+		super.destroy();
+	}
+}

+ 332 - 0
packages/ckeditor5-ui/tests/contextualballoon.js

@@ -0,0 +1,332 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+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 CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+/* global document */
+
+describe( 'ContextualBalloon', () => {
+	let editor, editorElement, balloon, viewA, viewB;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ContextualBalloon ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			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.
+			sinon.stub( balloon.view, 'attachTo', () => {} );
+		} );
+	} );
+
+	afterEach( () => {
+		editor.destroy();
+	} );
+
+	it( 'should be a plugin instance', () => {
+		expect( balloon ).to.instanceof( Plugin );
+	} );
+
+	describe( 'pluginName', () => {
+		it( 'should return plugin by name', () => {
+			expect( editor.plugins.get( 'contextualballoon' ) ).to.instanceof( ContextualBalloon );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'should create a plugin instance with properties', () => {
+			expect( balloon.view ).to.instanceof( BalloonPanelView );
+		} );
+
+		it( 'should add balloon panel view to editor `body` collection', () => {
+			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.above( -1 );
+		} );
+	} );
+
+	describe( 'hasView()', () => {
+		it( 'should return true when given view is in stack', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.hasView( viewA ) ).to.true;
+		} );
+
+		it( 'should return true when given view is in stack but is not visible', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+			expect( balloon.hasView( viewA ) ).to.true;
+		} );
+
+		it( 'should return false when given view is not in stack', () => {
+			expect( balloon.hasView( viewA ) ).to.false;
+		} );
+	} );
+
+	describe( 'add()', () => {
+		it( 'should add view to the stack and display in balloon', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.view.content.length ).to.equal( 1 );
+			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA );
+			expect( balloon.view.attachTo.calledOnce ).to.true;
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } );
+		} );
+
+		it( 'should throw an error when try to add the same view more than once', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( () => {
+				balloon.add( {
+					view: viewA,
+					position: { target: 'fake' }
+				} );
+			} ).to.throw( CKEditorError, /^contextualballoon-add-view-exist/ );
+		} );
+
+		it( 'should add multiple views to he stack and display last one', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.view.content.length ).to.equal( 1 );
+			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( {
+				view: viewB,
+				position: { target: 'fake', bar: 'biz' }
+			} );
+
+			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.secondCall.args[ 0 ] ).to.deep.equal( {
+				target: 'fake',
+				foo: 'bar'
+			} );
+		} );
+	} );
+
+	describe( 'visibleView', () => {
+		it( 'should return data of currently visible view', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+		} );
+
+		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( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+		} );
+
+		it( 'should return `null` when the stack is empty', () => {
+			expect( balloon.visibleView ).to.null;
+		} );
+	} );
+
+	describe( 'remove()', () => {
+		it( 'should remove given view and hide balloon when there is no other view to display', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visibleView ).to.null;
+		} );
+
+		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' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+		} );
+
+		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' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+		} );
+
+		it( 'should throw an error when there is no given view in the stack', () => {
+			expect( () => {
+				balloon.remove( viewA );
+			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
+		} );
+	} );
+
+	describe( 'updatePosition()', () => {
+		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.updatePosition();
+
+			expect( balloon.view.attachTo.calledOnce );
+			expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } );
+		} );
+
+		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( {
+				view: viewB,
+				position: {
+					target: 'fake',
+					bar: 'biz'
+				}
+			} );
+
+			balloon.view.attachTo.reset();
+
+			balloon.updatePosition();
+
+			expect( balloon.view.attachTo.calledOnce );
+			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' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+		} );
+
+		it( 'should throw an error when there is no given view in the stack', () => {
+			expect( () => {
+				balloon.remove( viewA );
+			} ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ );
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should balloon panel remove view from editor body collection', () => {
+			balloon.destroy();
+
+			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.equal( -1 );
+		} );
+	} );
+} );
+
+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'
+		} );
+	}
+}

+ 67 - 0
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.html

@@ -0,0 +1,67 @@
+<div id="editor">
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+	<p>Line of text</p>
+</div>
+
+<style>
+	.ck-editor {
+		margin: 5em auto;
+		max-width: 70%;
+	}
+
+	.plugin {
+		text-align: center;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: 10px;
+	}
+
+	.PluginA {
+		padding: 1em;
+	}
+
+	.PluginB {
+		padding: 2em;
+	}
+
+	.PluginC {
+		padding: 3em;
+	}
+
+	.plugin * {
+		text-align: center;
+	}
+
+	.plugin h2 {
+		font-weight: bold;
+		font-size: 16px;
+	}
+
+	.plugin h3 {
+		font-size: 14px;
+	}
+
+	.plugin .toolbar {
+		border: solid 1px #e1e1e1;
+		box-shadow: inset 0 0 5px rgba( 0, 0, 0, .3 );
+		margin: 10px 5px;
+		padding: 15px;
+		border-radius: 5px;
+	}
+</style>

+ 285 - 0
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.js

@@ -0,0 +1,285 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window, document, console:false */
+
+// This test implements contextual toolbar and few plugins which can be opened inside of the toolbar.
+
+// Code of this manual test should be successfully reduced when more of
+// CKE5 plugins will be integrated with ContextualBalloon and when
+// ContextualToolbar plugin will land as CKE5 plugin.
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+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 ContextualBalloon from '../../../src/contextualballoon';
+import ToolbarView from '../../../src/toolbar/toolbarview';
+import ButtonView from '../../../src/button/buttonview';
+import Template from '../../../src/template';
+import View from '../../../src/view';
+import clickOutsideHandler from '../../../src/bindings/clickoutsidehandler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+// Plugin view which displays toolbar with component to open next
+// plugin inside and cancel button to close currently visible plugin.
+class ViewA extends View {
+	constructor( locale ) {
+		super( locale );
+
+		this.keystrokes = new KeystrokeHandler();
+
+		this.toolbar = this.createCollection();
+
+		this.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this.fire( 'cancel' );
+			cancel();
+		} );
+
+		this.cancel = new ButtonView( locale );
+		this.cancel.label = 'Cancel';
+		this.cancel.withText = true;
+		this.cancel.on( 'execute', () => this.fire( 'cancel' ) );
+
+		this.template = new Template( {
+			tag: 'div',
+
+			attributes: {
+				class: [ 'plugin', this.bindTemplate.to( 'label' ) ],
+				tabindex: '-1'
+			},
+
+			children: [
+				{
+					tag: 'div',
+
+					children: [
+						{
+							tag: 'h2',
+
+							children: [
+								{ text: this.bindTemplate.to( 'label' ) },
+							]
+						},
+						{
+							tag: 'div',
+							attributes: {
+								class: [ 'toolbar' ]
+							},
+							children: [
+								{
+									tag: 'h3',
+									children: [
+										{ text: 'Open:' },
+									]
+								},
+								{
+									tag: 'div',
+									children: this.toolbar
+								},
+							]
+						},
+						this.cancel
+					]
+				}
+			]
+		} );
+	}
+
+	init() {
+		this.keystrokes.listenTo( this.element );
+
+		return super.init();
+	}
+}
+
+// Generic plugin class.
+class PluginGeneric extends Plugin {
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	init() {
+		this._balloon = this.editor.plugins.get( ContextualBalloon );
+
+		this.editor.editing.view.on( 'selectionChange', () => this._hidePanel() );
+
+		this.view.bind( 'label' ).to( this );
+
+		this.view.on( 'cancel', () => {
+			if ( this._isVisible ) {
+				this._hidePanel();
+			}
+		} );
+
+		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( this._isVisible ) {
+				this._hidePanel();
+			}
+
+			cancel();
+		} );
+
+		this.editor.ui.componentFactory.add( this.label, ( locale ) => {
+			const button = new ButtonView( locale );
+
+			button.label = this.label;
+			button.withText = true;
+			this.listenTo( button, 'execute', () => this._showPanel() );
+
+			return button;
+		} );
+
+		clickOutsideHandler( {
+			emitter: this.view,
+			activator: () => this._isVisible,
+			contextElement: this.view.element,
+			callback: () => this._hidePanel()
+		} );
+	}
+
+	get _isVisible() {
+		return this._balloon.visibleView === this.view;
+	}
+
+	afterInit() {
+		if ( this.buttons ) {
+			const toolbar = new ToolbarView();
+			this.view.toolbar.add( toolbar );
+
+			return toolbar.fillFromConfig( this.buttons, this.editor.ui.componentFactory );
+		}
+
+		return Promise.resolve();
+	}
+
+	_showPanel() {
+		if ( this._balloon.hasView( this.view ) ) {
+			return;
+		}
+
+		const viewDocument = this.editor.editing.view;
+
+		this._balloon.add( {
+			view: this.view,
+			position: {
+				target: viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() )
+			}
+		} );
+	}
+
+	_hidePanel() {
+		if ( !this._balloon.hasView( this.view ) ) {
+			return;
+		}
+
+		this._balloon.remove( this.view );
+	}
+}
+
+class PluginA extends PluginGeneric {
+	init() {
+		this.label = 'PluginA';
+		this.view = new ViewA( this.editor.locale );
+		this.buttons = [ 'PluginB' ];
+
+		super.init();
+	}
+}
+
+class PluginB extends PluginGeneric {
+	init() {
+		this.label = 'PluginB';
+		this.view = new ViewA( this.editor.locale );
+		this.buttons = [ 'PluginC' ];
+
+		super.init();
+	}
+}
+
+class PluginC extends PluginGeneric {
+	init() {
+		this.label = 'PluginC';
+		this.view = new ViewA( this.editor.locale );
+		this.buttons = [ 'PluginD' ];
+
+		super.init();
+	}
+}
+
+class PluginD extends PluginGeneric {
+	init() {
+		this.label = 'PluginD';
+		this.view = new ViewA( this.editor.locale );
+
+		super.init();
+	}
+}
+
+// Create contextual toolbar.
+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: 's'
+		} ),
+
+		backwardSelection: ( targetRect, balloonRect ) => ( {
+			top: targetRect.top - balloonRect.height - arrowVOffset,
+			left: targetRect.left - balloonRect.width / 2,
+			name: 'n'
+		} )
+	};
+
+	// Add plugins to the toolbar.
+	toolbar.fillFromConfig( [ 'PluginA', 'PluginB' ], editor.ui.componentFactory );
+
+	// Close toolbar when selection is changing.
+	editor.listenTo( editingView, 'selectionChange', () => close() );
+
+	// Handle when selection stop changing.
+	editor.listenTo( editingView, 'selectionChangeDone', () => {
+		// This implementation assumes that only non–collapsed selections gets the contextual toolbar.
+		if ( !editingView.selection.isCollapsed ) {
+			const isBackward = editingView.selection.isBackward;
+			const rangeRects = editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ).getClientRects();
+
+			balloon.add( {
+				view: toolbar,
+				position: {
+					target: isBackward ? rangeRects.item( 0 ) : rangeRects.item( rangeRects.length - 1 ),
+					positions: [ positions[ isBackward ? 'backwardSelection' : 'forwardSelection' ] ]
+				}
+			} );
+		}
+	} );
+
+	// Remove toolbar from balloon.
+	function close() {
+		if ( balloon.hasView( toolbar ) ) {
+			balloon.remove( toolbar );
+		}
+	}
+}
+
+// Finally the editor.
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ EssentialsPresets, Paragraph, PluginA, PluginB, PluginC, PluginD ],
+	toolbar: [ 'PluginA', 'PluginB' ]
+} )
+.then( editor => {
+	window.editor = editor;
+	createContextualToolbar( editor );
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 5 - 0
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.md

@@ -0,0 +1,5 @@
+1. Select some of text and play with the plugins.
+2. Keep opening panels inside the same balloon.
+3. Check if `Cancel` button closes plugins one by one.
+4. Check if `Esc` press closes plugins one by one (only when plugin or editable is focused).
+5. Check if panels stay opened in the same position as initial when more panels are open in the same balloon.