diff --git a/docs/api/widget.md b/docs/api/widget.md
index 4ad8a0b6..da1688f5 100644
--- a/docs/api/widget.md
+++ b/docs/api/widget.md
@@ -19,6 +19,11 @@ The widget API consists of two layers:
* The {@link module:widget/widget~Widget} plugin which enables base support for this feature. Usually, your plugin which implements a specific widget will define its reliance on the `Widget` plugin via its {@link module:core/plugin~Plugin.requires `Plugin.requires`} property.
* The {@link module:widget/utils~toWidget `toWidget()`} {@link module:widget/utils~toWidgetEditable `toWidgetEditable()`} functions which need to be used during the conversion in order to make a specific element either a widget or a widget's nested editable. See their documentation for more details.
+Besides the above mentioned core functionalities, this package implements the following utils:
+
+* The {@link module:widget/widgettoolbarrepository~WidgetToolbarRepository `WidgetToolbarRepository`} plugin which exposes a nice API for registering widget toolbars.
+* A couple of helper functions for managing widgets in the {@link module:widget/utils `@ckeditor/ckeditor5-widget/utils`} module.
+
The widget API is proposed in a very different way than it was in CKEditor 4. It is just a set of utilities that allow you to implement typical object-like entities. Most of the work actually happens in the {@link api/engine engine} and this API's role is only to properly conduct the engine.
diff --git a/package.json b/package.json
index d778b279..a41ed02b 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@ckeditor/ckeditor5-basic-styles": "^10.0.2",
+ "@ckeditor/ckeditor5-editor-balloon": "^11.0.0",
"@ckeditor/ckeditor5-editor-classic": "^11.0.0",
"@ckeditor/ckeditor5-essentials": "^10.1.1",
"@ckeditor/ckeditor5-paragraph": "^10.0.2",
diff --git a/src/widgettoolbarrepository.js b/src/widgettoolbarrepository.js
new file mode 100644
index 00000000..5497d8b4
--- /dev/null
+++ b/src/widgettoolbarrepository.js
@@ -0,0 +1,240 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/widgettoolbarrepository
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { isWidget } from './utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Widget toolbar repository plugin. A central point for registering widget toolbars. This plugin handles the whole
+ * toolbar rendering process and exposes a concise API.
+ *
+ * To add a toolbar for your widget use the {@link ~WidgetToolbarRepository#register `WidgetToolbarRepository#register()`} method.
+ *
+ * The following example comes from the {@link module:image/imagetoolbar~ImageToolbar} plugin:
+ *
+ * class ImageToolbar extends Plugin {
+ * static get requires() {
+ * return [ WidgetToolbarRepository ];
+ * }
+ *
+ * afterInit() {
+ * const editor = this.editor;
+ * const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+ *
+ * widgetToolbarRepository.register( 'image', {
+ * items: editor.config.get( 'image.toolbar' ),
+ * visibleWhen: viewSelection => isImageWidgetSelected( viewSelection )
+ * } );
+ * }
+ * }
+ */
+export default class WidgetToolbarRepository extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get requires() {
+ return [ ContextualBalloon ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'WidgetToolbarRepository';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ init() {
+ const editor = this.editor;
+ const balloonToolbar = editor.plugins.get( 'BalloonToolbar' );
+
+ // Disables the default balloon toolbar for all widgets.
+ if ( balloonToolbar ) {
+ this.listenTo( balloonToolbar, 'show', evt => {
+ if ( isWidgetSelected( editor.editing.view.document.selection ) ) {
+ evt.stop();
+ }
+ }, { priority: 'high' } );
+ }
+
+ /**
+ * A map of toolbars.
+ *
+ * @protected
+ * @member {Map.} #_toolbars
+ */
+ this._toolbars = new Map();
+
+ /**
+ * @private
+ */
+ this._balloon = this.editor.plugins.get( 'ContextualBalloon' );
+
+ this.listenTo( editor.ui, 'update', () => {
+ this._updateToolbarsVisibility();
+ } );
+
+ // UI#update is not fired after focus is back in editor, we need to check if balloon panel should be visible.
+ this.listenTo( editor.ui.focusTracker, 'change:isFocused', () => {
+ this._updateToolbarsVisibility();
+ }, { priority: 'low' } );
+ }
+
+ /**
+ * Registers toolbar in the WidgetToolbarRepository. It renders it in the `ContextualBalloon` based on the value of the invoked
+ * `visibleWhen` function. Toolbar items are gathered from `items` array.
+ * The balloon's CSS class is by default `ck-toolbar-container` and may be override with the `balloonClassName` option.
+ *
+ * Note: This method should be called in the {@link module:core/plugin~PluginInterface#afterInit `Plugin#afterInit()`}
+ * callback (or later) to make sure that the given toolbar items were already registered by other plugins.
+ *
+ * @param {String} toolbarId An id for the toolbar. Used to
+ * @param {Object} options
+ * @param {Array.} options.items Array of toolbar items.
+ * @param {Function} options.visibleWhen Callback which specifies when the toolbar should be visible for the widget.
+ * @param {String} [options.balloonClassName='ck-toolbar-container'] CSS class for the widget balloon.
+ */
+ register( toolbarId, { items, visibleWhen, balloonClassName = 'ck-toolbar-container' } ) {
+ const editor = this.editor;
+ const toolbarView = new ToolbarView();
+
+ if ( this._toolbars.has( toolbarId ) ) {
+ /**
+ * Toolbar with the given id was already added.
+ *
+ * @error widget-toolbar-duplicated
+ * @param toolbarId Toolbar id.
+ */
+ throw new CKEditorError( 'widget-toolbar-duplicated: Toolbar with the given id was already added.', { toolbarId } );
+ }
+
+ toolbarView.fillFromConfig( items, editor.ui.componentFactory );
+
+ this._toolbars.set( toolbarId, {
+ view: toolbarView,
+ visibleWhen,
+ balloonClassName,
+ } );
+ }
+
+ /**
+ * Iterates over stored toolbars and makes them visible or hidden.
+ *
+ * @private
+ */
+ _updateToolbarsVisibility() {
+ for ( const toolbar of this._toolbars.values() ) {
+ if ( !this.editor.ui.focusTracker.isFocused || !toolbar.visibleWhen( this.editor.editing.view.document.selection ) ) {
+ this._hideToolbar( toolbar );
+ } else {
+ this._showToolbar( toolbar );
+ }
+ }
+ }
+
+ /**
+ * Hides the given toolbar.
+ *
+ * @private
+ * @param {Object} toolbar
+ */
+ _hideToolbar( toolbar ) {
+ if ( !this._isToolbarVisible( toolbar ) ) {
+ return;
+ }
+
+ this._balloon.remove( toolbar.view );
+ }
+
+ /**
+ * Shows up the toolbar if the toolbar is not visible and repositions the toolbar's balloon when toolbar's
+ * view is the most top view in balloon stack.
+ *
+ * It might happen here that the toolbar's view is under another view. Then do nothing as the other toolbar view
+ * should be still visible after the {@link module:core/editor/editorui~EditorUI#event:update}.
+ *
+ * @private
+ * @param {Object} toolbar
+ */
+ _showToolbar( toolbar ) {
+ if ( this._isToolbarVisible( toolbar ) ) {
+ repositionContextualBalloon( this.editor );
+ } else if ( !this._balloon.hasView( toolbar.view ) ) {
+ this._balloon.add( {
+ view: toolbar.view,
+ position: getBalloonPositionData( this.editor ),
+ balloonClassName: toolbar.balloonClassName,
+ } );
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} toolbar
+ */
+ _isToolbarVisible( toolbar ) {
+ return this._balloon.visibleView == toolbar.view;
+ }
+}
+
+function repositionContextualBalloon( editor ) {
+ const balloon = editor.plugins.get( 'ContextualBalloon' );
+ const position = getBalloonPositionData( editor );
+
+ balloon.updatePosition( position );
+}
+
+function getBalloonPositionData( editor ) {
+ const editingView = editor.editing.view;
+ const defaultPositions = BalloonPanelView.defaultPositions;
+ const widget = getParentWidget( editingView.document.selection );
+
+ return {
+ target: editingView.domConverter.viewToDom( widget ),
+ positions: [
+ defaultPositions.northArrowSouth,
+ defaultPositions.northArrowSouthWest,
+ defaultPositions.northArrowSouthEast,
+ defaultPositions.southArrowNorth,
+ defaultPositions.southArrowNorthWest,
+ defaultPositions.southArrowNorthEast
+ ]
+ };
+}
+
+function getParentWidget( selection ) {
+ const selectedElement = selection.getSelectedElement();
+
+ if ( selectedElement && isWidget( selectedElement ) ) {
+ return selectedElement;
+ }
+
+ const position = selection.getFirstPosition();
+ let parent = position.parent;
+
+ while ( parent ) {
+ if ( parent.is( 'element' ) && isWidget( parent ) ) {
+ return parent;
+ }
+
+ parent = parent.parent;
+ }
+}
+
+function isWidgetSelected( selection ) {
+ const viewElement = selection.getSelectedElement();
+
+ return !!( viewElement && isWidget( viewElement ) );
+}
diff --git a/tests/widgettoolbarrepository.js b/tests/widgettoolbarrepository.js
new file mode 100644
index 00000000..81ea441a
--- /dev/null
+++ b/tests/widgettoolbarrepository.js
@@ -0,0 +1,344 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Widget from '../src/widget';
+import WidgetToolbarRepository from '../src/widgettoolbarrepository';
+import { isWidget, toWidget } from '../src/utils';
+import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'WidgetToolbarRepository', () => {
+ let editor, model, balloon, widgetToolbarRepository, editorElement;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+
+ return ClassicTestEditor
+ .create( editorElement, {
+ plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget ],
+ fake: {
+ toolbar: [ 'fake_button' ]
+ }
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ model = newEditor.model;
+ widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+ balloon = editor.plugins.get( 'ContextualBalloon' );
+ } );
+ } );
+
+ afterEach( () => {
+ editorElement.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'should be loaded', () => {
+ expect( editor.plugins.get( WidgetToolbarRepository ) ).to.be.instanceOf( WidgetToolbarRepository );
+ } );
+
+ describe( 'register()', () => {
+ it( 'should create a widget toolbar and add it to the collection', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: () => false,
+ } );
+
+ expect( widgetToolbarRepository._toolbars.size ).to.equal( 1 );
+ expect( widgetToolbarRepository._toolbars.get( 'fake' ) ).to.be.an( 'object' );
+ } );
+
+ it( 'should throw when adding two times widget with the same id', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: () => false
+ } );
+
+ expect( () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: () => false
+ } );
+ } ).to.throw( CKEditorError, /^widget-toolbar-duplicated/ );
+ } );
+ } );
+
+ describe( 'integration tests', () => {
+ beforeEach( () => {
+ editor.ui.focusTracker.isFocused = true;
+ } );
+
+ it( 'toolbar should be visible when the `visibleWhen` callback returns true', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ setData( model, 'foo[]' );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+ expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+ } );
+
+ it( 'toolbar should be hidden when the `visibleWhen` callback returns false', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ setData( model, '[foo]' );
+
+ expect( balloon.visibleView ).to.equal( null );
+ } );
+
+ it( 'toolbar should be hidden when the `visibleWhen` callback returns false #2', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ setData( model, 'foo[]' );
+
+ model.change( writer => {
+ // Select the foo.
+ writer.setSelection( model.document.getRoot().getChild( 0 ), 'in' );
+ } );
+
+ expect( balloon.visibleView ).to.equal( null );
+ } );
+
+ it( 'toolbar should update its position when other widget is selected', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ setData( model, '[]' );
+
+ model.change( writer => {
+ // Select the second widget.
+ writer.setSelection( model.document.getRoot().getChild( 1 ), 'on' );
+ } );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+ expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+ } );
+
+ it( 'it should be possible to create a widget toolbar for content inside the widget', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetContentSelected
+ } );
+
+ setData( model, '[foo]' );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+ expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+ } );
+
+ it( 'toolbar should not engage when is in the balloon yet invisible', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+ setData( model, '[]' );
+
+ expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+
+ const lastView = new View();
+ lastView.element = document.createElement( 'div' );
+
+ balloon.add( {
+ view: lastView,
+ position: {
+ target: document.body
+ }
+ } );
+
+ expect( balloon.visibleView ).to.equal( lastView );
+
+ editor.ui.fire( 'update' );
+
+ expect( balloon.visibleView ).to.equal( lastView );
+ } );
+ } );
+} );
+
+describe( 'WidgetToolbarRepository - integration with the BalloonToolbar', () => {
+ let clock, editor, model, balloon, balloonToolbar, widgetToolbarRepository, editorElement;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+ clock = testUtils.sinon.useFakeTimers();
+
+ return BalloonEditor
+ .create( editorElement, {
+ plugins: [ Paragraph, FakeButton, WidgetToolbarRepository, FakeWidget, Bold ],
+ balloonToolbar: [ 'bold' ],
+ fake: {
+ toolbar: [ 'fake_button' ]
+ }
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ model = newEditor.model;
+ widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
+ balloon = editor.plugins.get( 'ContextualBalloon' );
+ balloonToolbar = editor.plugins.get( 'BalloonToolbar' );
+
+ editor.editing.view.document.isFocused = true;
+ } );
+ } );
+
+ afterEach( () => {
+ editorElement.remove();
+
+ return editor.destroy();
+ } );
+
+ it( 'balloon toolbar should be hidden when the widget is selected', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected,
+ } );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbars.get( 'fake' ).view;
+
+ setData( model, '[]foo' );
+ editor.ui.focusTracker.isFocused = true;
+
+ clock.tick( 200 );
+
+ expect( balloon.visibleView ).to.equal( fakeWidgetToolbarView );
+ } );
+
+ it( 'balloon toolbar should be visible when the widget is not selected', () => {
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ visibleWhen: isFakeWidgetSelected
+ } );
+
+ setData( model, '[foo]' );
+ editor.ui.focusTracker.isFocused = true;
+
+ clock.tick( 200 );
+
+ expect( balloon.visibleView ).to.equal( balloonToolbar.toolbarView );
+ } );
+} );
+
+const fakeWidgetSymbol = Symbol( 'fakeWidget' );
+
+function isFakeWidgetSelected( selection ) {
+ const viewElement = selection.getSelectedElement();
+
+ return !!viewElement && isWidget( viewElement ) && !!viewElement.getCustomProperty( fakeWidgetSymbol );
+}
+
+function isFakeWidgetContentSelected( selection ) {
+ const pos = selection.getFirstPosition();
+ let node = pos.parent;
+
+ while ( node ) {
+ if ( node.is( 'element' ) && isWidget( node ) && node.getCustomProperty( fakeWidgetSymbol ) ) {
+ return true;
+ }
+
+ node = node.parent;
+ }
+
+ return false;
+}
+
+// Plugin that adds fake_button to editor's component factory.
+class FakeButton extends Plugin {
+ init() {
+ this.editor.ui.componentFactory.add( 'fake_button', locale => {
+ const view = new ButtonView( locale );
+
+ view.set( {
+ label: 'fake button'
+ } );
+
+ return view;
+ } );
+ }
+}
+
+// Simple widget plugin
+// It registers `` block in model and represents `div` in the view.
+// It allows having text inside self.
+class FakeWidget extends Plugin {
+ static get requires() {
+ return [ Widget ];
+ }
+
+ init() {
+ const editor = this.editor;
+ const schema = editor.model.schema;
+
+ schema.register( 'fake-widget', {
+ isObject: true,
+ isBlock: true,
+ allowWhere: '$block',
+ } );
+
+ schema.extend( '$text', { allowIn: 'fake-widget' } );
+
+ const conversion = editor.conversion;
+
+ conversion.for( 'dataDowncast' ).add( downcastElementToElement( {
+ model: 'fake-widget',
+ view: ( modelElement, viewWriter ) => {
+ return viewWriter.createContainerElement( 'div' );
+ }
+ } ) );
+
+ conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+ model: 'fake-widget',
+ view: ( modelElement, viewWriter ) => {
+ const fakeWidget = viewWriter.createContainerElement( 'div' );
+ viewWriter.setCustomProperty( fakeWidgetSymbol, true, fakeWidget );
+
+ return toWidget( fakeWidget, viewWriter, { label: 'fake-widget' } );
+ }
+ } ) );
+
+ conversion.for( 'upcast' ).add( upcastElementToElement( {
+ view: {
+ name: 'div'
+ },
+ model: ( view, modelWriter ) => {
+ return modelWriter.createElement( 'fake-widget' );
+ }
+ } ) );
+ }
+}