ckeditor5-image-upload-base64
Version:
The development environment of CKEditor 5 – the best browser-based rich text editor.
439 lines (384 loc) • 13.9 kB
JavaScript
/**
* @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 ui/toolbar/balloon/balloontoolbar
*/
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 FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
import normalizeToolbarConfig from '../normalizetoolbarconfig';
import { debounce } from 'lodash-es';
import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
const toPx = toUnit( 'px' );
/**
* The contextual toolbar.
*
* It uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
*
* @extends module:core/plugin~Plugin
*/
export default class BalloonToolbar extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'BalloonToolbar';
}
/**
* @inheritDoc
*/
static get requires() {
return [ ContextualBalloon ];
}
/**
* @inheritDoc
*/
constructor( editor ) {
super( editor );
/**
* A cached and normalized `config.balloonToolbar` object.
*
* @type {module:core/editor/editorconfig~EditorConfig#balloonToolbar}
* @private
*/
this._balloonConfig = normalizeToolbarConfig( editor.config.get( 'balloonToolbar' ) );
/**
* The toolbar view displayed in the balloon.
*
* @type {module:ui/toolbar/toolbarview~ToolbarView}
*/
this.toolbarView = this._createToolbarView();
/**
* Tracks the focus of the {@link module:core/editor/editorui~EditorUI#getEditableElement editable element}
* and the {@link #toolbarView}. When both are blurred then the toolbar should hide.
*
* @readonly
* @type {module:utils:focustracker~FocusTracker}
*/
this.focusTracker = new FocusTracker();
// Wait for the EditorUI#init. EditableElement is not available before.
editor.ui.once( 'ready', () => {
this.focusTracker.add( editor.ui.getEditableElement() );
this.focusTracker.add( this.toolbarView.element );
} );
/**
* An instance of the resize observer that allows to respond to changes in editable's geometry
* so the toolbar can stay within its boundaries (and group toolbar items that do not fit).
*
* **Note**: Used only when `shouldNotGroupWhenFull` was **not** set in the
* {@link module:core/editor/editorconfig~EditorConfig#balloonToolbar configuration}.
*
* **Note:** Created in {@link #init}.
*
* @protected
* @member {module:utils/dom/resizeobserver~ResizeObserver}
*/
this._resizeObserver = null;
/**
* The contextual balloon plugin instance.
*
* @private
* @type {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
*/
this._balloon = 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
* @type {Function}
*/
this._fireSelectionChangeDebounced = debounce( () => this.fire( '_selectionChangeDebounced' ), 200 );
// The appearance of the BalloonToolbar method is event–driven.
// It is possible to stop the #show event and this prevent the toolbar from showing up.
this.decorate( 'show' );
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const selection = editor.model.document.selection;
// Show/hide the toolbar on editable focus/blur.
this.listenTo( this.focusTracker, 'change:isFocused', ( evt, name, isFocused ) => {
const isToolbarVisible = this._balloon.visibleView === this.toolbarView;
if ( !isFocused && isToolbarVisible ) {
this.hide();
} else if ( isFocused ) {
this.show();
}
} );
// Hide the toolbar when the selection is changed by a direct change or has changed to collapsed.
this.listenTo( selection, 'change:range', ( evt, data ) => {
if ( data.directChange || selection.isCollapsed ) {
this.hide();
}
// Fire internal `_selectionChangeDebounced` event to use it for showing
// the toolbar after the selection stops changing.
this._fireSelectionChangeDebounced();
} );
// Show the toolbar when the selection stops changing.
this.listenTo( this, '_selectionChangeDebounced', () => {
if ( this.editor.editing.view.document.isFocused ) {
this.show();
}
} );
if ( !this._balloonConfig.shouldNotGroupWhenFull ) {
this.listenTo( editor, 'ready', () => {
const editableElement = editor.ui.view.editable.element;
// Set #toolbarView's max-width on the initialization and update it on the editable resize.
this._resizeObserver = new ResizeObserver( editableElement, () => {
// The max-width equals 90% of the editable's width for the best user experience.
// The value keeps the balloon very close to the boundaries of the editable and limits the cases
// when the balloon juts out from the editable element it belongs to.
this.toolbarView.maxWidth = toPx( new Rect( editableElement ).width * .9 );
} );
} );
}
// Listen to the toolbar view and whenever it changes its geometry due to some items being
// grouped or ungrouped, update the position of the balloon because a shorter/longer toolbar
// means the balloon could be pointing at the wrong place. Once updated, the balloon will point
// at the right selection in the content again.
// https://github.com/ckeditor/ckeditor5/issues/6444
this.listenTo( this.toolbarView, 'groupedItemsUpdate', () => {
this._updatePosition();
} );
}
/**
* Creates toolbar components based on given configuration.
* This needs to be done when all plugins are ready.
*
* @inheritDoc
*/
afterInit() {
const factory = this.editor.ui.componentFactory;
this.toolbarView.fillFromConfig( this._balloonConfig.items, factory );
}
/**
* Creates the toolbar view instance.
*
* @private
* @returns {module:ui/toolbar/toolbarview~ToolbarView}
*/
_createToolbarView() {
const shouldGroupWhenFull = !this._balloonConfig.shouldNotGroupWhenFull;
const toolbarView = new ToolbarView( this.editor.locale, {
shouldGroupWhenFull
} );
toolbarView.extendTemplate( {
attributes: {
class: [ 'ck-toolbar_floating' ]
}
} );
toolbarView.render();
return toolbarView;
}
/**
* Shows the toolbar and attaches it to the selection.
*
* Fires {@link #event:show} event which can be stopped to prevent the toolbar from showing up.
*/
show() {
const editor = this.editor;
const selection = editor.model.document.selection;
const schema = editor.model.schema;
// Do not add the toolbar to the balloon stack twice.
if ( this._balloon.hasView( this.toolbarView ) ) {
return;
}
// Do not show the toolbar when the selection is collapsed.
if ( selection.isCollapsed ) {
return;
}
// Do not show the toolbar when there is more than one range in the selection and they fully contain selectable elements.
// See https://github.com/ckeditor/ckeditor5/issues/6443.
if ( selectionContainsOnlyMultipleSelectables( selection, schema ) ) {
return;
}
// Don not show the toolbar when all components inside are disabled
// see https://github.com/ckeditor/ckeditor5-ui/issues/269.
if ( Array.from( this.toolbarView.items ).every( item => item.isEnabled !== undefined && !item.isEnabled ) ) {
return;
}
// Update the toolbar position when the editor ui should be refreshed.
this.listenTo( this.editor.ui, 'update', () => {
this._updatePosition();
} );
// Add the toolbar to the common editor contextual balloon.
this._balloon.add( {
view: this.toolbarView,
position: this._getBalloonPositionData(),
balloonClassName: 'ck-toolbar-container'
} );
}
/**
* Hides the toolbar.
*/
hide() {
if ( this._balloon.hasView( this.toolbarView ) ) {
this.stopListening( this.editor.ui, 'update' );
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 editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const viewSelection = viewDocument.selection;
// Get direction of the selection.
const isBackward = viewDocument.selection.isBackward;
return {
// Because the target for BalloonPanelView is a Rect (not DOMRange), it's geometry will stay fixed
// as the window scrolls. To let the BalloonPanelView follow such Rect, is must be continuously
// computed and hence, the target is defined as a function instead of a static value.
// https://github.com/ckeditor/ckeditor5-ui/issues/195
target: () => {
const range = isBackward ? viewSelection.getFirstRange() : viewSelection.getLastRange();
const rangeRects = Rect.getDomRangeRects( view.domConverter.viewRangeToDom( range ) );
// Select the proper range rect depending on the direction of the selection.
if ( isBackward ) {
return rangeRects[ 0 ];
} else {
// Ditch the zero-width "orphan" rect in the next line for the forward selection if there's
// another one preceding it. It is not rendered as a selection by the web browser anyway.
// https://github.com/ckeditor/ckeditor5-ui/issues/308
if ( rangeRects.length > 1 && rangeRects[ rangeRects.length - 1 ].width === 0 ) {
rangeRects.pop();
}
return rangeRects[ rangeRects.length - 1 ];
}
},
positions: getBalloonPositions( isBackward )
};
}
/**
* Updates the position of the {@link #_balloon} to make up for changes:
*
* * in the geometry of the selection it is attached to (e.g. the selection moved in the viewport or expanded or shrunk),
* * or the geometry of the balloon toolbar itself (e.g. the toolbar has grouped or ungrouped some items and it is shorter or longer).
*
* @private
*/
_updatePosition() {
this._balloon.updatePosition( this._getBalloonPositionData() );
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.stopListening();
this._fireSelectionChangeDebounced.cancel();
this.toolbarView.destroy();
this.focusTracker.destroy();
if ( this._resizeObserver ) {
this._resizeObserver.destroy();
}
}
/**
* This event is fired just before the toolbar shows up. Stopping this event will prevent this.
*
* @event show
*/
/**
* 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
*/
}
// Returns toolbar positions for the given direction of the selection.
//
// @private
// @param {Boolean} isBackward
// @returns {Array.<module:utils/dom/position~Position>}
function getBalloonPositions( isBackward ) {
const defaultPositions = BalloonPanelView.defaultPositions;
return isBackward ? [
defaultPositions.northWestArrowSouth,
defaultPositions.northWestArrowSouthWest,
defaultPositions.northWestArrowSouthEast,
defaultPositions.northWestArrowSouthMiddleEast,
defaultPositions.northWestArrowSouthMiddleWest,
defaultPositions.southWestArrowNorth,
defaultPositions.southWestArrowNorthWest,
defaultPositions.southWestArrowNorthEast,
defaultPositions.southWestArrowNorthMiddleWest,
defaultPositions.southWestArrowNorthMiddleEast
] : [
defaultPositions.southEastArrowNorth,
defaultPositions.southEastArrowNorthEast,
defaultPositions.southEastArrowNorthWest,
defaultPositions.southEastArrowNorthMiddleEast,
defaultPositions.southEastArrowNorthMiddleWest,
defaultPositions.northEastArrowSouth,
defaultPositions.northEastArrowSouthEast,
defaultPositions.northEastArrowSouthWest,
defaultPositions.northEastArrowSouthMiddleEast,
defaultPositions.northEastArrowSouthMiddleWest
];
}
// Returns "true" when the selection has multiple ranges and each range contains a selectable element
// and nothing else.
//
// @private
// @param {module:engine/model/selection~Selection} selection
// @param {module:engine/model/schema~Schema} schema
// @returns {Boolean}
function selectionContainsOnlyMultipleSelectables( selection, schema ) {
// It doesn't contain multiple objects if there is only one range.
if ( selection.rangeCount === 1 ) {
return false;
}
return [ ...selection.getRanges() ].every( range => {
const element = range.getContainedElement();
return element && schema.isSelectable( element );
} );
}
/**
* Contextual toolbar configuration. Used by the {@link module:ui/toolbar/balloon/balloontoolbar~BalloonToolbar}
* feature.
*
* ## Configuring toolbar items
*
* const config = {
* balloonToolbar: [ 'bold', 'italic', 'undo', 'redo' ]
* };
*
* You can also use `'|'` to create a separator between groups of items:
*
* const config = {
* balloonToolbar: [ 'bold', 'italic', | 'undo', 'redo' ]
* };
*
* Read also about configuring the main editor toolbar in {@link module:core/editor/editorconfig~EditorConfig#toolbar}.
*
* ## Configuring items grouping
*
* You can prevent automatic items grouping by setting the `shouldNotGroupWhenFull` option:
*
* const config = {
* balloonToolbar: {
* items: [ 'bold', 'italic', 'undo', 'redo' ],
* shouldNotGroupWhenFull: true
* },
* };
*
* @member {Array.<String>|Object} module:core/editor/editorconfig~EditorConfig#balloonToolbar
*/