src/widgettoolbarrepository.js
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @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' ),
* getRelatedElement: getSelectedImageWidget
* } );
* }
* }
*/
export default class WidgetToolbarRepository extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ ContextualBalloon ];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'WidgetToolbarRepository';
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
// Disables the default balloon toolbar for all widgets.
if ( editor.plugins.has( 'BalloonToolbar' ) ) {
const balloonToolbar = editor.plugins.get( 'BalloonToolbar' );
this.listenTo( balloonToolbar, 'show', evt => {
if ( isWidgetSelected( editor.editing.view.document.selection ) ) {
evt.stop();
}
}, { priority: 'high' } );
}
/**
* A map of toolbar definitions.
*
* @protected
* @member {Map.<String,module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition>} #_toolbarDefinitions
*/
this._toolbarDefinitions = new Map();
/**
* @private
*/
this._balloon = this.editor.plugins.get( 'ContextualBalloon' );
this.on( 'change:isEnabled', () => {
this._updateToolbarsVisibility();
} );
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' } );
}
destroy() {
super.destroy();
for ( const toolbarConfig of this._toolbarDefinitions.values() ) {
toolbarConfig.view.destroy();
}
}
/**
* Registers toolbar in the WidgetToolbarRepository. It renders it in the `ContextualBalloon` based on the value of the invoked
* `getRelatedElement` 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 {String} [options.ariaLabel] Label used by assistive technologies to describe this toolbar element.
* @param {Array.<String>} options.items Array of toolbar items.
* @param {Function} options.getRelatedElement Callback which returns an element the toolbar should be attached to.
* @param {String} [options.balloonClassName='ck-toolbar-container'] CSS class for the widget balloon.
*/
register( toolbarId, { ariaLabel, items, getRelatedElement, balloonClassName = 'ck-toolbar-container' } ) {
const editor = this.editor;
const t = editor.t;
const toolbarView = new ToolbarView( editor.locale );
toolbarView.ariaLabel = ariaLabel || t( 'Widget toolbar' );
if ( this._toolbarDefinitions.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.', this, { toolbarId } );
}
toolbarView.fillFromConfig( items, editor.ui.componentFactory );
this._toolbarDefinitions.set( toolbarId, {
view: toolbarView,
getRelatedElement,
balloonClassName
} );
}
/**
* Iterates over stored toolbars and makes them visible or hidden.
*
* @private
*/
_updateToolbarsVisibility() {
let maxRelatedElementDepth = 0;
let deepestRelatedElement = null;
let deepestToolbarDefinition = null;
for ( const definition of this._toolbarDefinitions.values() ) {
const relatedElement = definition.getRelatedElement( this.editor.editing.view.document.selection );
if ( !this.isEnabled || !relatedElement ) {
if ( this._isToolbarInBalloon( definition ) ) {
this._hideToolbar( definition );
}
} else if ( !this.editor.ui.focusTracker.isFocused ) {
if ( this._isToolbarVisible( definition ) ) {
this._hideToolbar( definition );
}
} else {
const relatedElementDepth = relatedElement.getAncestors().length;
// Many toolbars can express willingness to be displayed but they do not know about
// each other. Figure out which toolbar is deepest in the view tree to decide which
// should be displayed. For instance, if a selected image is inside a table cell, display
// the ImageToolbar rather than the TableToolbar (#60).
if ( relatedElementDepth > maxRelatedElementDepth ) {
maxRelatedElementDepth = relatedElementDepth;
deepestRelatedElement = relatedElement;
deepestToolbarDefinition = definition;
}
}
}
if ( deepestToolbarDefinition ) {
this._showToolbar( deepestToolbarDefinition, deepestRelatedElement );
}
}
/**
* Hides the given toolbar.
*
* @private
* @param {module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition} toolbarDefinition
*/
_hideToolbar( toolbarDefinition ) {
this._balloon.remove( toolbarDefinition.view );
this.stopListening( this._balloon, 'change:visibleView' );
}
/**
* Shows up the toolbar if the toolbar is not visible.
* Otherwise, 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 {module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition} toolbarDefinition
* @param {module:engine/view/element~Element} relatedElement
*/
_showToolbar( toolbarDefinition, relatedElement ) {
if ( this._isToolbarVisible( toolbarDefinition ) ) {
repositionContextualBalloon( this.editor, relatedElement );
} else if ( !this._isToolbarInBalloon( toolbarDefinition ) ) {
this._balloon.add( {
view: toolbarDefinition.view,
position: getBalloonPositionData( this.editor, relatedElement ),
balloonClassName: toolbarDefinition.balloonClassName
} );
// Update toolbar position each time stack with toolbar view is switched to visible.
// This is in a case target element has changed when toolbar was in invisible stack
// e.g. target image was wrapped by a block quote.
// See https://github.com/ckeditor/ckeditor5-widget/issues/92.
this.listenTo( this._balloon, 'change:visibleView', () => {
for ( const definition of this._toolbarDefinitions.values() ) {
if ( this._isToolbarVisible( definition ) ) {
const relatedElement = definition.getRelatedElement( this.editor.editing.view.document.selection );
repositionContextualBalloon( this.editor, relatedElement );
}
}
} );
}
}
/**
* @private
* @param {Object} toolbar
* @returns {Boolean}
*/
_isToolbarVisible( toolbar ) {
return this._balloon.visibleView === toolbar.view;
}
/**
* @private
* @param {Object} toolbar
* @returns {Boolean}
*/
_isToolbarInBalloon( toolbar ) {
return this._balloon.hasView( toolbar.view );
}
}
function repositionContextualBalloon( editor, relatedElement ) {
const balloon = editor.plugins.get( 'ContextualBalloon' );
const position = getBalloonPositionData( editor, relatedElement );
balloon.updatePosition( position );
}
function getBalloonPositionData( editor, relatedElement ) {
const editingView = editor.editing.view;
const defaultPositions = BalloonPanelView.defaultPositions;
return {
target: editingView.domConverter.mapViewToDom( relatedElement ),
positions: [
defaultPositions.northArrowSouth,
defaultPositions.northArrowSouthWest,
defaultPositions.northArrowSouthEast,
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
defaultPositions.southArrowNorthEast
]
};
}
function isWidgetSelected( selection ) {
const viewElement = selection.getSelectedElement();
return !!( viewElement && isWidget( viewElement ) );
}
/**
* The toolbar definition object used by the toolbar repository to manage toolbars.
* It contains information necessary to display the toolbar in the
* {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} and
* update it during its life (display) cycle.
*
* @typedef {Object} module:widget/widgettoolbarrepository~WidgetRepositoryToolbarDefinition
*
* @property {module:ui/view~View} view The UI view of the toolbar.
* @property {Function} getRelatedElement A function that returns an engine {@link module:engine/view/view~View}
* element the toolbar is to be attached to. For instance, an image widget or a table widget (or `null` when
* there is no such element). The function accepts an instance of {@link module:engine/view/selection~Selection}.
* @property {String} balloonClassName CSS class for the widget balloon when a toolbar is displayed.
*/