@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
282 lines (281 loc) • 11.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/menubar/menubarview
*/
import { logWarning } from '@ckeditor/ckeditor5-utils';
import View from '../view.js';
import { isObject } from 'es-toolkit/compat';
import ListItemView from '../list/listitemview.js';
import ListSeparatorView from '../list/listseparatorview.js';
import MenuBarMenuView from './menubarmenuview.js';
import MenuBarMenuListView from './menubarmenulistview.js';
import MenuBarMenuListItemView from './menubarmenulistitemview.js';
import MenuBarMenuListItemButtonView from './menubarmenulistitembuttonview.js';
import MenuBarMenuListItemFileDialogButtonView from './menubarmenulistitemfiledialogbuttonview.js';
import { MenuBarBehaviors, processMenuBarConfig } from './utils.js';
const EVENT_NAME_DELEGATES = ['mouseenter', 'arrowleft', 'arrowright', 'change:isOpen'];
import '../../theme/components/menubar/menubar.css';
/**
* The application menu bar component. It brings a set of top-level menus (and sub-menus) that can be used
* to organize and access a large number of buttons.
*/
export default class MenuBarView extends View {
/**
* Collection of the child views inside the {@link #element}.
*/
children;
/**
* A list of {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} instances registered in the menu bar.
*
* @observable
*/
menus = [];
/**
* Creates an instance of the menu bar view.
*
* @param locale The localization services instance.
*/
constructor(locale) {
super(locale);
const t = locale.t;
const bind = this.bindTemplate;
this.set({
isOpen: false,
isFocusBorderEnabled: false
});
this._setupIsOpenUpdater();
this.children = this.createCollection();
// @if CK_DEBUG_MENU_BAR // // Logs events in the main event bus of the component.
// @if CK_DEBUG_MENU_BAR // this.on( 'menu', ( evt, data ) => {
// @if CK_DEBUG_MENU_BAR // console.log( `MenuBarView:${ evt.name }`, evt.path.map( view => view.element ) );
// @if CK_DEBUG_MENU_BAR // } );
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-menu-bar',
bind.if('isFocusBorderEnabled', 'ck-menu-bar_focus-border-enabled')
],
'aria-label': t('Editor menu bar'),
role: 'menubar'
},
children: this.children
});
}
/**
* A utility that expands a plain menu bar configuration into a structure of menus (also: sub-menus)
* and items using a given {@link module:ui/componentfactory~ComponentFactory component factory}.
*
* See the {@link module:core/editor/editorconfig~EditorConfig#menuBar menu bar} in the editor
* configuration reference to learn how to configure the menu bar.
*/
fillFromConfig(config, componentFactory, extraItems = []) {
const locale = this.locale;
const processedConfig = processMenuBarConfig({
normalizedConfig: config,
locale,
componentFactory,
extraItems
});
const topLevelCategoryMenuViews = processedConfig.items.map(menuDefinition => this._createMenu({
componentFactory,
menuDefinition
}));
this.children.addMany(topLevelCategoryMenuViews);
}
/**
* @inheritDoc
*/
render() {
super.render();
MenuBarBehaviors.toggleMenusAndFocusItemsOnHover(this);
MenuBarBehaviors.closeMenusWhenTheBarCloses(this);
MenuBarBehaviors.closeMenuWhenAnotherOnTheSameLevelOpens(this);
MenuBarBehaviors.focusCycleMenusOnArrows(this);
MenuBarBehaviors.closeOnClickOutside(this);
MenuBarBehaviors.enableFocusHighlightOnInteraction(this);
}
/**
* Focuses the menu bar.
*/
focus() {
if (this.children.first) {
this.children.first.focus();
}
}
/**
* Closes all menus in the bar.
*/
close() {
for (const topLevelCategoryMenuView of this.children) {
topLevelCategoryMenuView.isOpen = false;
}
}
/**
* Disables all menus in the bar.
*/
disable() {
for (const topLevelCategoryMenuView of this.children) {
topLevelCategoryMenuView.isEnabled = false;
}
}
/**
* Enables all menus in the bar.
*/
enable() {
for (const topLevelCategoryMenuView of this.children) {
topLevelCategoryMenuView.isEnabled = true;
}
}
/**
* Registers a menu view in the menu bar. Every {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} instance must be registered
* in the menu bar to be properly managed.
*/
registerMenu(menuView, parentMenuView = null) {
if (parentMenuView) {
menuView.delegate(...EVENT_NAME_DELEGATES).to(parentMenuView);
menuView.parentMenuView = parentMenuView;
}
else {
menuView.delegate(...EVENT_NAME_DELEGATES).to(this, name => 'menu:' + name);
}
menuView._attachBehaviors();
this.menus.push(menuView);
}
/**
* Creates a {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} based on the given definition.
*/
_createMenu({ componentFactory, menuDefinition, parentMenuView }) {
const locale = this.locale;
const menuView = new MenuBarMenuView(locale);
this.registerMenu(menuView, parentMenuView);
menuView.buttonView.set({
label: menuDefinition.label
});
// Defer the creation of the menu structure until it gets open. This is a performance optimization
// that shortens the time needed to create the editor.
menuView.once('change:isOpen', () => {
const listView = new MenuBarMenuListView(locale);
listView.ariaLabel = menuDefinition.label;
menuView.panelView.children.add(listView);
listView.items.addMany(this._createMenuItems({ menuDefinition, parentMenuView: menuView, componentFactory }));
});
return menuView;
}
/**
* Creates a {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} items based on the given definition.
*/
_createMenuItems({ menuDefinition, parentMenuView, componentFactory }) {
const locale = this.locale;
const items = [];
for (const menuGroupDefinition of menuDefinition.groups) {
for (const itemDefinition of menuGroupDefinition.items) {
const menuItemView = new MenuBarMenuListItemView(locale, parentMenuView);
if (isObject(itemDefinition)) {
menuItemView.children.add(this._createMenu({
componentFactory,
menuDefinition: itemDefinition,
parentMenuView
}));
}
else {
const componentView = this._createMenuItemContentFromFactory({
componentName: itemDefinition,
componentFactory,
parentMenuView
});
if (!componentView) {
continue;
}
menuItemView.children.add(componentView);
}
items.push(menuItemView);
}
// Separate groups with a separator.
if (menuGroupDefinition !== menuDefinition.groups[menuDefinition.groups.length - 1]) {
items.push(new ListSeparatorView(locale));
}
}
return items;
}
/**
* Uses the component factory to create a content of the menu item (a button or a sub-menu).
*/
_createMenuItemContentFromFactory({ componentName, parentMenuView, componentFactory }) {
const componentView = componentFactory.create(componentName);
if (!(componentView instanceof MenuBarMenuView ||
componentView instanceof MenuBarMenuListItemButtonView ||
componentView instanceof MenuBarMenuListItemFileDialogButtonView)) {
/**
* Adding unsupported components to the {@link module:ui/menubar/menubarview~MenuBarView} is not possible.
*
* A component should be either a {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} (sub-menu) or a
* {@link module:ui/menubar/menubarmenulistitembuttonview~MenuBarMenuListItemButtonView} (button).
*
* @error menu-bar-component-unsupported
* @param {string} componentName A name of the unsupported component used in the configuration.
* @param {module:ui/view~View} componentView An unsupported component view.
*/
logWarning('menu-bar-component-unsupported', {
componentName,
componentView
});
return null;
}
this._registerMenuTree(componentView, parentMenuView);
// Close the whole menu bar when a component is executed.
componentView.on('execute', () => {
this.close();
});
return componentView;
}
/**
* Checks component and its children recursively and calls {@link #registerMenu}
* for each item that is {@link module:ui/menubar/menubarmenuview~MenuBarMenuView}.
*
* @internal
*/
_registerMenuTree(componentView, parentMenuView) {
if (!(componentView instanceof MenuBarMenuView)) {
componentView.delegate('mouseenter').to(parentMenuView);
return;
}
this.registerMenu(componentView, parentMenuView);
const menuBarItemsList = componentView.panelView.children
.filter(child => child instanceof MenuBarMenuListView)[0];
if (!menuBarItemsList) {
componentView.delegate('mouseenter').to(parentMenuView);
return;
}
const nonSeparatorItems = menuBarItemsList.items.filter(item => item instanceof ListItemView);
for (const item of nonSeparatorItems) {
this._registerMenuTree(item.children.get(0), componentView);
}
}
/**
* Manages the state of the {@link #isOpen} property of the menu bar. Because the state is a sum of individual
* top-level menus' states, it's necessary to listen to their changes and update the state accordingly.
*
* Additionally, it prevents from unnecessary changes of `isOpen` when one top-level menu opens and another closes
* (regardless of in which order), maintaining a stable `isOpen === true` in that situation.
*/
_setupIsOpenUpdater() {
let closeTimeout;
// TODO: This is not the prettiest approach but at least it's simple.
this.on('menu:change:isOpen', (evt, name, isOpen) => {
clearTimeout(closeTimeout);
if (isOpen) {
this.isOpen = true;
}
else {
closeTimeout = setTimeout(() => {
this.isOpen = Array.from(this.children).some(menuView => menuView.isOpen);
}, 0);
}
});
}
}