@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
616 lines (615 loc) • 22.5 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module ui/panel/balloon/contextualballoon
*/
import BalloonPanelView from './balloonpanelview.js';
import View from '../../view.js';
import ButtonView from '../../button/buttonview.js';
import { Plugin } from '@ckeditor/ckeditor5-core';
import { CKEditorError, FocusTracker, Rect, toUnit } from '@ckeditor/ckeditor5-utils';
import { IconNextArrow, IconPreviousArrow } from '@ckeditor/ckeditor5-icons';
import '../../../theme/components/panel/balloonrotator.css';
import '../../../theme/components/panel/fakepanel.css';
const toPx = /* #__PURE__ */ toUnit('px');
/**
* Provides the common contextual balloon for the editor.
*
* The role of this plugin is to unify the contextual balloons logic, simplify views management and help
* avoid the unnecessary complexity of handling multiple {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
* instances in the editor.
*
* This plugin allows for creating single or multiple panel stacks.
*
* Each stack may have multiple views, with the one on the top being visible. When the visible view is removed from the stack,
* the previous view becomes visible.
*
* It might be useful to implement nested navigation in a balloon. For instance, a toolbar view may contain a link button.
* When you click it, a link view (which lets you set the URL) is created and put on top of the toolbar view, so the link panel
* is displayed. When you finish editing the link and close (remove) the link view, the toolbar view is visible again.
*
* However, there are cases when there are multiple independent balloons to be displayed, for instance, if the selection
* is inside two inline comments at the same time. For such cases, you can create two independent panel stacks.
* The contextual balloon plugin will create a navigation bar to let the users switch between these panel stacks using the "Next"
* and "Previous" buttons.
*
* If there are no views in the current stack, the balloon panel will try to switch to the next stack. If there are no
* panels in any stack, the balloon panel will be hidden.
*
* **Note**: To force the balloon panel to show only one view, even if there are other stacks, use the `singleViewMode=true` option
* when {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#add adding} a view to a panel.
*
* From the implementation point of view, the contextual ballon plugin is reusing a single
* {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance to display multiple contextual balloon
* panels in the editor. It also creates a special {@link module:ui/panel/balloon/contextualballoon~RotatorView rotator view},
* used to manage multiple panel stacks. Rotator view is a child of the balloon panel view and the parent of the specific
* view you want to display. If there is more than one panel stack to be displayed, the rotator view will add a
* navigation bar. If there is only one stack, the rotator view is transparent (it does not add any UI elements).
*/
export default class ContextualBalloon extends Plugin {
/**
* The {@link module:utils/dom/position~Options#limiter position limiter}
* for the {@link #view balloon}, used when no `limiter` has been passed into {@link #add}
* or {@link #updatePosition}.
*
* By default, a function that obtains the farthest DOM
* {@link module:engine/view/rooteditableelement~RootEditableElement}
* of the {@link module:engine/view/document~Document#selection}.
*/
positionLimiter;
visibleStack;
/**
* The map of views and their stacks.
*/
_viewToStack = new Map();
/**
* The map of IDs and stacks.
*/
_idToStack = new Map();
/**
* The common balloon panel view.
*/
_view = null;
/**
* Rotator view embedded in the contextual balloon.
* Displays the currently visible view in the balloon and provides navigation for switching stacks.
*/
_rotatorView = null;
/**
* Displays fake panels under the balloon panel view when multiple stacks are added to the balloon.
*/
_fakePanelsView = null;
/**
* @inheritDoc
*/
static get pluginName() {
return 'ContextualBalloon';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this.positionLimiter = () => {
const view = this.editor.editing.view;
const viewDocument = view.document;
const editableElement = viewDocument.selection.editableElement;
if (editableElement) {
return view.domConverter.mapViewToDom(editableElement.root);
}
return null;
};
this.decorate('getPositionOptions');
this.set('visibleView', null);
this.set('_numberOfStacks', 0);
this.set('_singleViewMode', false);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
if (this._view) {
this._view.destroy();
}
if (this._rotatorView) {
this._rotatorView.destroy();
}
if (this._fakePanelsView) {
this._fakePanelsView.destroy();
}
}
/**
* The common balloon panel view.
*/
get view() {
if (!this._view) {
this._createPanelView();
}
return this._view;
}
/**
* Returns `true` when the given view is in one of the stacks. Otherwise returns `false`.
*/
hasView(view) {
return Array.from(this._viewToStack.keys()).includes(view);
}
/**
* Adds a new view to the stack and makes it visible if the current stack is visible
* or it is the first view in the balloon.
*
* @param data The configuration of the view.
* @param data.stackId The ID of the stack that the view is added to. Defaults to `'main'`.
* @param data.view The content of the balloon.
* @param data.position Positioning options.
* @param data.balloonClassName An additional CSS class added to the {@link #view balloon} when visible.
* @param data.withArrow Whether the {@link #view balloon} should be rendered with an arrow. Defaults to `true`.
* @param data.singleViewMode Whether the view should be the only visible view even if other stacks were added. Defaults to `false`.
*/
add(data) {
if (!this._view) {
this._createPanelView();
}
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', [this, data]);
}
const stackId = data.stackId || 'main';
// If new stack is added, creates it and show view from this stack.
if (!this._idToStack.has(stackId)) {
this._idToStack.set(stackId, new Map([[data.view, data]]));
this._viewToStack.set(data.view, this._idToStack.get(stackId));
this._numberOfStacks = this._idToStack.size;
if (!this._visibleStack || data.singleViewMode) {
this.showStack(stackId);
}
return;
}
const stack = this._idToStack.get(stackId);
if (data.singleViewMode) {
this.showStack(stackId);
}
// Add new view to the stack.
stack.set(data.view, data);
this._viewToStack.set(data.view, stack);
// And display it if is added to the currently visible stack.
if (stack === this._visibleStack) {
this._showView(data);
}
}
/**
* Removes the given view from the stack. If the removed view was visible,
* the view preceding it in the stack will become visible instead.
* When there is no view in the stack, the next stack will be displayed.
* When there are no more stacks, the balloon will hide.
*
* @param view A view to be removed from the balloon.
*/
remove(view) {
if (!this.hasView(view)) {
/**
* Trying to remove the configuration of the view not defined in the stack.
*
* @error contextualballoon-remove-view-not-exist
*/
throw new CKEditorError('contextualballoon-remove-view-not-exist', [this, view]);
}
const stack = this._viewToStack.get(view);
if (this._singleViewMode && this.visibleView === view) {
this._singleViewMode = false;
}
// When visible view will be removed we need to show a preceding view or next stack
// if a view is the only view in the stack.
if (this.visibleView === view) {
if (stack.size === 1) {
if (this._idToStack.size > 1) {
this._showNextStack();
}
else {
this.view.hide();
this.visibleView = null;
this._rotatorView.hideView();
}
}
else {
this._showView(Array.from(stack.values())[stack.size - 2]);
}
}
if (stack.size === 1) {
this._idToStack.delete(this._getStackId(stack));
this._numberOfStacks = this._idToStack.size;
}
else {
stack.delete(view);
}
this._viewToStack.delete(view);
}
/**
* Updates the position of the balloon using the position data of the first visible view in the stack.
* When new position data is given, the position data of the currently visible view will be updated.
*
* @param position Position options.
*/
updatePosition(position) {
if (position) {
this._visibleStack.get(this.visibleView).position = position;
}
this.view.pin(this.getPositionOptions());
this._fakePanelsView.updatePosition();
}
/**
* Returns position options of the last view in the stack.
* This keeps the balloon in the same position when the view is changed.
*/
getPositionOptions() {
let position = Array.from(this._visibleStack.values()).pop().position;
if (position) {
// Use the default limiter if none has been specified.
if (!position.limiter) {
// Don't modify the original options object.
position = Object.assign({}, position, {
limiter: this.positionLimiter
});
}
// Don't modify the original options object.
position = Object.assign({}, position, {
viewportOffsetConfig: {
...this.editor.ui.viewportOffset,
top: this.editor.ui.viewportOffset.visualTop
}
});
}
return position;
}
/**
* Shows the last view from the stack of a given ID.
*/
showStack(id) {
this.visibleStack = id;
const stack = this._idToStack.get(id);
if (!stack) {
/**
* Trying to show a stack that does not exist.
*
* @error contextualballoon-showstack-stack-not-exist
*/
throw new CKEditorError('contextualballoon-showstack-stack-not-exist', this);
}
if (this._visibleStack === stack) {
return;
}
this._showView(Array.from(stack.values()).pop());
}
/**
* Initializes view instances.
*/
_createPanelView() {
this._view = new BalloonPanelView(this.editor.locale);
this.editor.ui.view.body.add(this._view);
this._rotatorView = this._createRotatorView();
this._fakePanelsView = this._createFakePanelsView();
}
/**
* Returns the stack of the currently visible view.
*/
get _visibleStack() {
return this._viewToStack.get(this.visibleView);
}
/**
* Returns the ID of the given stack.
*/
_getStackId(stack) {
const entry = Array.from(this._idToStack.entries()).find(entry => entry[1] === stack);
return entry[0];
}
/**
* Shows the last view from the next stack.
*/
_showNextStack() {
const stacks = Array.from(this._idToStack.values());
let nextIndex = stacks.indexOf(this._visibleStack) + 1;
if (!stacks[nextIndex]) {
nextIndex = 0;
}
this.showStack(this._getStackId(stacks[nextIndex]));
}
/**
* Shows the last view from the previous stack.
*/
_showPrevStack() {
const stacks = Array.from(this._idToStack.values());
let nextIndex = stacks.indexOf(this._visibleStack) - 1;
if (!stacks[nextIndex]) {
nextIndex = stacks.length - 1;
}
this.showStack(this._getStackId(stacks[nextIndex]));
}
/**
* Creates a rotator view.
*/
_createRotatorView() {
const view = new RotatorView(this.editor.locale);
const t = this.editor.locale.t;
this.view.content.add(view);
// Hide navigation when there is only a one stack & not in single view mode.
view.bind('isNavigationVisible').to(this, '_numberOfStacks', this, '_singleViewMode', (value, isSingleViewMode) => {
return !isSingleViewMode && value > 1;
});
// Update balloon position after toggling navigation.
view.on('change:isNavigationVisible', () => (this.updatePosition()), { priority: 'low' });
// Update stacks counter value.
view.bind('counter').to(this, 'visibleView', this, '_numberOfStacks', (visibleView, numberOfStacks) => {
if (numberOfStacks < 2) {
return '';
}
const current = Array.from(this._idToStack.values()).indexOf(this._visibleStack) + 1;
return t('%0 of %1', [current, numberOfStacks]);
});
view.buttonNextView.on('execute', () => {
// When current view has a focus then move focus to the editable before removing it,
// otherwise editor will lost focus.
if (view.focusTracker.isFocused) {
this.editor.editing.view.focus();
}
this._showNextStack();
});
view.buttonPrevView.on('execute', () => {
// When current view has a focus then move focus to the editable before removing it,
// otherwise editor will lost focus.
if (view.focusTracker.isFocused) {
this.editor.editing.view.focus();
}
this._showPrevStack();
});
return view;
}
/**
* Creates a fake panels view.
*/
_createFakePanelsView() {
const view = new FakePanelsView(this.editor.locale, this.view);
view.bind('numberOfPanels').to(this, '_numberOfStacks', this, '_singleViewMode', (number, isSingleViewMode) => {
const showPanels = !isSingleViewMode && number >= 2;
return showPanels ? Math.min(number - 1, 2) : 0;
});
view.listenTo(this.view, 'change:top', () => view.updatePosition());
view.listenTo(this.view, 'change:left', () => view.updatePosition());
this.editor.ui.view.body.add(view);
return view;
}
/**
* Sets the view as the content of the balloon and attaches the balloon using position
* options of the first view.
*
* @param data Configuration.
* @param data.view The view to show in the balloon.
* @param data.balloonClassName Additional class name which will be added to the {@link #view balloon}.
* @param data.withArrow Whether the {@link #view balloon} should be rendered with an arrow.
*/
_showView({ view, balloonClassName = '', withArrow = true, singleViewMode = false }) {
this.view.class = balloonClassName;
this.view.withArrow = withArrow;
this._rotatorView.showView(view);
this.visibleView = view;
this.view.pin(this.getPositionOptions());
this._fakePanelsView.updatePosition();
if (singleViewMode) {
this._singleViewMode = true;
}
}
}
/**
* Rotator view is a helper class for the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon}.
* It is used for displaying the last view from the current stack and providing navigation buttons for switching stacks.
* See the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon} documentation to learn more.
*/
export class RotatorView extends View {
/**
* Used for checking if a view is focused or not.
*/
focusTracker;
/**
* Navigation button for switching the stack to the previous one.
*/
buttonPrevView;
/**
* Navigation button for switching the stack to the next one.
*/
buttonNextView;
/**
* A collection of the child views that creates the rotator content.
*/
content;
/**
* @inheritDoc
*/
constructor(locale) {
super(locale);
const t = locale.t;
const bind = this.bindTemplate;
this.set('isNavigationVisible', true);
this.focusTracker = new FocusTracker();
this.buttonPrevView = this._createButtonView(t('Previous'), IconPreviousArrow);
this.buttonNextView = this._createButtonView(t('Next'), IconNextArrow);
this.content = this.createCollection();
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-balloon-rotator'
],
'z-index': '-1'
},
children: [
{
tag: 'div',
attributes: {
class: [
'ck-balloon-rotator__navigation',
bind.to('isNavigationVisible', value => value ? '' : 'ck-hidden')
]
},
children: [
this.buttonPrevView,
{
tag: 'span',
attributes: {
class: [
'ck-balloon-rotator__counter'
]
},
children: [
{
text: bind.to('counter')
}
]
},
this.buttonNextView
]
},
{
tag: 'div',
attributes: {
class: 'ck-balloon-rotator__content'
},
children: this.content
}
]
});
}
/**
* @inheritDoc
*/
render() {
super.render();
this.focusTracker.add(this.element);
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
}
/**
* Shows a given view.
*
* @param view The view to show.
*/
showView(view) {
this.hideView();
this.content.add(view);
}
/**
* Hides the currently displayed view.
*/
hideView() {
this.content.clear();
}
/**
* Creates a navigation button view.
*
* @param label The button label.
* @param icon The button icon.
*/
_createButtonView(label, icon) {
const view = new ButtonView(this.locale);
view.set({
label,
icon,
tooltip: true
});
return view;
}
}
/**
* Displays additional layers under the balloon when multiple stacks are added to the balloon.
*/
class FakePanelsView extends View {
/**
* Collection of the child views which creates fake panel content.
*/
content;
/**
* Context.
*/
_balloonPanelView;
/**
* @inheritDoc
*/
constructor(locale, balloonPanelView) {
super(locale);
const bind = this.bindTemplate;
this.set('top', 0);
this.set('left', 0);
this.set('height', 0);
this.set('width', 0);
this.set('numberOfPanels', 0);
this.content = this.createCollection();
this._balloonPanelView = balloonPanelView;
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck-fake-panel',
bind.to('numberOfPanels', number => number ? '' : 'ck-hidden')
],
style: {
top: bind.to('top', toPx),
left: bind.to('left', toPx),
width: bind.to('width', toPx),
height: bind.to('height', toPx)
}
},
children: this.content
});
this.on('change:numberOfPanels', (evt, name, next, prev) => {
if (next > prev) {
this._addPanels(next - prev);
}
else {
this._removePanels(prev - next);
}
this.updatePosition();
});
}
_addPanels(number) {
while (number--) {
const view = new View();
view.setTemplate({ tag: 'div' });
this.content.add(view);
this.registerChild(view);
}
}
_removePanels(number) {
while (number--) {
const view = this.content.last;
this.content.remove(view);
this.deregisterChild(view);
view.destroy();
}
}
/**
* Updates coordinates of fake panels.
*/
updatePosition() {
if (this.numberOfPanels) {
const { top, left } = this._balloonPanelView;
const { width, height } = new Rect(this._balloonPanelView.element);
Object.assign(this, { top, left, width, height });
}
}
}