@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
1,418 lines (1,417 loc) • 49.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/utils
*/
import MenuBarMenuListItemView from './menubarmenulistitemview.js';
import clickOutsideHandler from '../bindings/clickoutsidehandler.js';
import { logWarning } from '@ckeditor/ckeditor5-utils';
import { cloneDeep } from 'es-toolkit/compat';
const NESTED_PANEL_HORIZONTAL_OFFSET = 5;
/**
* Behaviors of the {@link module:ui/menubar/menubarview~MenuBarView} component.
*/
export const MenuBarBehaviors = {
/**
* When the bar is already open:
* * Opens the menu when the user hovers over its button.
* * Closes open menu when another menu's button gets hovered.
*/
toggleMenusAndFocusItemsOnHover(menuBarView) {
menuBarView.on('menu:mouseenter', evt => {
// This behavior should be activated when one of condition is present:
// 1. The user opened any submenu of menubar and hover over items in the menu bar.
// 2. The user focused whole menubar using keyboard interaction and enabled focus borders and hover over items in the menu bar.
if (!menuBarView.isFocusBorderEnabled && !menuBarView.isOpen) {
return;
}
if (menuBarView.isOpen) {
for (const menuView of menuBarView.menus) {
// @if CK_DEBUG_MENU_BAR // const wasOpen = menuView.isOpen;
const pathLeaf = evt.path[0];
const isListItemContainingMenu = pathLeaf instanceof MenuBarMenuListItemView && pathLeaf.children.first === menuView;
menuView.isOpen = (evt.path.includes(menuView) || isListItemContainingMenu) && menuView.isEnabled;
// @if CK_DEBUG_MENU_BAR // if ( wasOpen !== menuView.isOpen ) {
// @if CK_DEBUG_MENU_BAR // console.log( '[BEHAVIOR] toggleMenusAndFocusItemsOnHover(): Toggle',
// @if CK_DEBUG_MENU_BAR // logMenu( menuView ), 'isOpen', menuView.isOpen
// @if CK_DEBUG_MENU_BAR // );
// @if CK_DEBUG_MENU_BAR // }
}
}
evt.source.focus();
});
},
/**
* Moves between top-level menus using the arrow left and right keys.
*
* If the menubar has already been open, the arrow keys move focus between top-level menu buttons and open them.
* If the menubar is closed, the arrow keys only move focus between top-level menu buttons.
*/
focusCycleMenusOnArrows(menuBarView) {
const isContentRTL = menuBarView.locale.uiLanguageDirection === 'rtl';
menuBarView.on('menu:arrowright', evt => {
cycleTopLevelMenus(evt.source, isContentRTL ? -1 : 1);
});
menuBarView.on('menu:arrowleft', evt => {
cycleTopLevelMenus(evt.source, isContentRTL ? 1 : -1);
});
function cycleTopLevelMenus(currentMenuView, step) {
const currentIndex = menuBarView.children.getIndex(currentMenuView);
const isCurrentMenuViewOpen = currentMenuView.isOpen;
const menusCount = menuBarView.children.length;
const menuViewToOpen = menuBarView.children.get((currentIndex + menusCount + step) % menusCount);
currentMenuView.isOpen = false;
if (isCurrentMenuViewOpen) {
menuViewToOpen.isOpen = true;
}
menuViewToOpen.buttonView.focus();
}
},
/**
* Closes the entire sub-menu structure when the bar is closed. This prevents sub-menus from being open if the user
* closes the entire bar, and then re-opens some top-level menu.
*/
closeMenusWhenTheBarCloses(menuBarView) {
menuBarView.on('change:isOpen', () => {
if (!menuBarView.isOpen) {
menuBarView.menus.forEach(menuView => {
menuView.isOpen = false;
// @if CK_DEBUG_MENU_BAR // console.log( '[BEHAVIOR] closeMenusWhenTheBarCloses(): Closing', logMenu( menuView ) );
});
}
});
},
/**
* Handles the following case:
* 1. Hover to open a sub-menu (A). The button has focus.
* 2. Press arrow up/down to move focus to another sub-menu (B) button.
* 3. Press arrow right to open the sub-menu (B).
* 4. The sub-menu (A) should close as it would with `toggleMenusAndFocusItemsOnHover()`.
*/
closeMenuWhenAnotherOnTheSameLevelOpens(menuBarView) {
menuBarView.on('menu:change:isOpen', (evt, name, isOpen) => {
if (isOpen) {
menuBarView.menus
.filter(menuView => {
return evt.source.parentMenuView === menuView.parentMenuView &&
evt.source !== menuView &&
menuView.isOpen;
}).forEach(menuView => {
menuView.isOpen = false;
// @if CK_DEBUG_MENU_BAR // console.log( '[BEHAVIOR] closeMenuWhenAnotherOpens(): Closing', logMenu( menuView ) );
});
}
});
},
/**
* Closes the bar when the user clicked outside of it (page body, editor root, etc.).
*/
closeOnClickOutside(menuBarView) {
clickOutsideHandler({
emitter: menuBarView,
activator: () => menuBarView.isOpen,
callback: () => menuBarView.close(),
contextElements: () => menuBarView.children.map(child => child.element)
});
},
/**
* Tracks the keyboard focus interaction on the menu bar view. It is used to determine if the nested items
* of the menu bar should render focus rings after first interaction with the keyboard.
*/
enableFocusHighlightOnInteraction(menuBarView) {
let isKeyPressed = false;
menuBarView.on('change:isOpen', (_, evt, isOpen) => {
if (!isOpen) {
// Keep the focus border if the menu bar was closed by a keyboard interaction (Esc key).
// The user remains in the keyboard navigation mode and can traverse the main categories.
// See https://github.com/ckeditor/ckeditor5/issues/16719.
if (!isKeyPressed) {
menuBarView.isFocusBorderEnabled = false;
}
// Reset the flag when the menu bar is closed, menu items tend to intercept `keyup` event
// and sometimes, after pressing `enter` on focused item, `isKeyPressed` stuck in `true` state.
isKeyPressed = false;
}
});
menuBarView.listenTo(menuBarView.element, 'keydown', () => {
isKeyPressed = true;
}, { useCapture: true });
menuBarView.listenTo(menuBarView.element, 'keyup', () => {
isKeyPressed = false;
}, { useCapture: true });
menuBarView.listenTo(menuBarView.element, 'focus', () => {
if (isKeyPressed) {
menuBarView.isFocusBorderEnabled = true;
}
}, { useCapture: true });
}
};
/**
* Behaviors of the {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} component.
*/
export const MenuBarMenuBehaviors = {
/**
* If the button of the menu is focused, pressing the arrow down key should open the panel and focus it.
* This is analogous to the {@link module:ui/dropdown/dropdownview~DropdownView}.
*/
openAndFocusPanelOnArrowDownKey(menuView) {
menuView.keystrokes.set('arrowdown', (data, cancel) => {
if (menuView.isEnabled && menuView.focusTracker.focusedElement === menuView.buttonView.element) {
if (!menuView.isOpen) {
menuView.isOpen = true;
}
menuView.panelView.focus();
cancel();
}
});
},
/**
* Open the menu on the right arrow key press. This allows for navigating to sub-menus using the keyboard.
*/
openOnArrowRightKey(menuView) {
const keystroke = menuView.locale.uiLanguageDirection === 'rtl' ? 'arrowleft' : 'arrowright';
menuView.keystrokes.set(keystroke, (data, cancel) => {
if (menuView.focusTracker.focusedElement !== menuView.buttonView.element || !menuView.isEnabled) {
return;
}
// @if CK_DEBUG_MENU_BAR // console.log( '[BEHAVIOR] openOnArrowRightKey(): Opening', logMenu( menuView ) );
if (!menuView.isOpen) {
menuView.isOpen = true;
}
menuView.panelView.focus();
cancel();
});
},
/**
* Opens the menu on its button click. Note that this behavior only opens but never closes the menu (unlike
* {@link module:ui/dropdown/dropdownview~DropdownView}).
*/
openOnButtonClick(menuView) {
menuView.buttonView.on('execute', () => {
menuView.isOpen = true;
});
},
/**
* Toggles the menu on its button click. This behavior is analogous to {@link module:ui/dropdown/dropdownview~DropdownView}.
*/
toggleOnButtonClick(menuView) {
menuView.buttonView.on('execute', () => {
menuView.isOpen = !menuView.isOpen;
});
},
/**
* Opens the menu and focuses the panel content upon pressing the Enter key.
*/
openAndFocusOnEnterKeyPress(menuView) {
menuView.keystrokes.set('enter', (data, cancel) => {
// Engage only for Enter key press when the button is focused. The panel can contain
// other UI components and features that rely on the Enter key press.
if (menuView.focusTracker.focusedElement !== menuView.buttonView.element) {
return;
}
menuView.isOpen = true;
menuView.panelView.focus();
cancel();
});
},
/**
* Closes the menu on the right left key press. This allows for navigating to sub-menus using the keyboard.
*/
closeOnArrowLeftKey(menuView) {
const keystroke = menuView.locale.uiLanguageDirection === 'rtl' ? 'arrowright' : 'arrowleft';
menuView.keystrokes.set(keystroke, (data, cancel) => {
if (menuView.isOpen) {
menuView.isOpen = false;
menuView.focus();
cancel();
}
});
},
/**
* Closes the menu on the esc key press. This allows for navigating to sub-menus using the keyboard.
*/
closeOnEscKey(menuView) {
menuView.keystrokes.set('esc', (data, cancel) => {
if (menuView.isOpen) {
menuView.isOpen = false;
menuView.focus();
cancel();
}
});
},
/**
* Closes the menu when its parent menu also closed. This prevents from orphaned open menus when the parent menu re-opens.
*/
closeOnParentClose(menuView) {
menuView.parentMenuView.on('change:isOpen', (evt, name, isOpen) => {
if (!isOpen && evt.source === menuView.parentMenuView) {
// @if CK_DEBUG_MENU_BAR // console.log( '[BEHAVIOR] closeOnParentClose(): Closing', logMenu( menuView ) );
menuView.isOpen = false;
}
});
}
};
// @if CK_DEBUG_MENU_BAR // function logMenu( menuView: MenuBarMenuView ) {
// @if CK_DEBUG_MENU_BAR // return `"${ menuView.buttonView.label }"`;
// @if CK_DEBUG_MENU_BAR // }
/**
* Contains every positioning function used by {@link module:ui/menubar/menubarmenuview~MenuBarMenuView} that decides where the
* {@link module:ui/menubar/menubarmenuview~MenuBarMenuView#panelView} should be placed.
*
* Top-level menu positioning functions:
*
* ┌──────┐
* │ │
* ├──────┴────────┐
* │ │
* │ │
* │ │
* │ SE │
* └───────────────┘
*
* ┌──────┐
* │ │
* ┌────────┴──────┤
* │ │
* │ │
* │ │
* │ SW │
* └───────────────┘
*
* ┌───────────────┐
* │ NW │
* │ │
* │ │
* │ │
* └────────┬──────┤
* │ │
* └──────┘
*
* ┌───────────────┐
* │ NE │
* │ │
* │ │
* │ │
* ├──────┬────────┘
* │ │
* └──────┘
*
* Sub-menu positioning functions:
*
* ┌──────┬───────────────┐
* │ │ │
* └──────┤ │
* │ │
* │ ES │
* └───────────────┘
*
* ┌───────────────┬──────┐
* │ │ │
* │ ├──────┘
* │ │
* │ WS │
* └───────────────┘
*
* ┌───────────────┐
* │ EN │
* │ │
* ┌──────┤ │
* │ │ │
* └──────┴───────────────┘
*
* ┌───────────────┐
* │ WN │
* │ │
* │ ├──────┐
* │ │ │
* └───────────────┴──────┘
*/
export const MenuBarMenuViewPanelPositioningFunctions = {
southEast: buttonRect => {
return {
top: buttonRect.bottom,
left: buttonRect.left,
name: 'se'
};
},
southWest: (buttonRect, panelRect) => {
return {
top: buttonRect.bottom,
left: buttonRect.left - panelRect.width + buttonRect.width,
name: 'sw'
};
},
northEast: (buttonRect, panelRect) => {
return {
top: buttonRect.top - panelRect.height,
left: buttonRect.left,
name: 'ne'
};
},
northWest: (buttonRect, panelRect) => {
return {
top: buttonRect.top - panelRect.height,
left: buttonRect.left - panelRect.width + buttonRect.width,
name: 'nw'
};
},
eastSouth: buttonRect => {
return {
top: buttonRect.top,
left: buttonRect.right - NESTED_PANEL_HORIZONTAL_OFFSET,
name: 'es'
};
},
eastNorth: (buttonRect, panelRect) => {
return {
top: buttonRect.top - panelRect.height,
left: buttonRect.right - NESTED_PANEL_HORIZONTAL_OFFSET,
name: 'en'
};
},
westSouth: (buttonRect, panelRect) => {
return {
top: buttonRect.top,
left: buttonRect.left - panelRect.width + NESTED_PANEL_HORIZONTAL_OFFSET,
name: 'ws'
};
},
westNorth: (buttonRect, panelRect) => {
return {
top: buttonRect.top - panelRect.height,
left: buttonRect.left - panelRect.width + NESTED_PANEL_HORIZONTAL_OFFSET,
name: 'wn'
};
}
};
/**
* The default items {@link module:core/editor/editorconfig~EditorConfig#menuBar configuration} of the
* {@link module:ui/menubar/menubarview~MenuBarView} component. It contains names of all menu bar components
* registered in the {@link module:ui/componentfactory~ComponentFactory component factory} (available in the project).
*
* **Note**: Menu bar component names provided by core editor features are prefixed with `menuBar:` in order to distinguish
* them from components referenced by the {@link module:core/editor/editorconfig~EditorConfig#toolbar toolbar configuration}, for instance,
* `'menuBar:bold'` is a menu bar button but `'bold'` is a toolbar button.
*
* Below is the preset menu bar structure (the default value of `config.menuBar.items` property):
*
* ```ts
* [
* {
* menuId: 'file',
* label: 'File',
* groups: [
* {
* groupId: 'export',
* items: [
* 'menuBar:exportPdf',
* 'menuBar:exportWord'
* ]
* },
* {
* groupId: 'import',
* items: [
* 'menuBar:importWord'
* ]
* },
* {
* groupId: 'revisionHistory',
* items: [
* 'menuBar:revisionHistory'
* ]
* }
* ]
* },
* {
* menuId: 'edit',
* label: 'Edit',
* groups: [
* {
* groupId: 'undo',
* items: [
* 'menuBar:undo',
* 'menuBar:redo'
* ]
* },
* {
* groupId: 'selectAll',
* items: [
* 'menuBar:selectAll'
* ]
* },
* {
* groupId: 'findAndReplace',
* items: [
* 'menuBar:findAndReplace'
* ]
* }
* ]
* },
* {
* menuId: 'view',
* label: 'View',
* groups: [
* {
* groupId: 'sourceEditingEnhanced',
* items: [
* 'menuBar:sourceEditingEnhanced'
* ]
* },
* {
* groupId: 'sourceEditing',
* items: [
* 'menuBar:sourceEditing'
* ]
* },
* {
* groupId: 'showBlocks',
* items: [
* 'menuBar:showBlocks'
* ]
* },
* {
* groupId: 'previewMergeFields',
* items: [
* 'menuBar:previewMergeFields'
* ]
* },
* {
* groupId: 'fullscreen',
* items: [
* 'menuBar:fullscreen'
* ]
* },
* {
* groupId: 'restrictedEditingException',
* items: [
* 'menuBar:restrictedEditingException'
* ]
* }
* ]
* },
* {
* menuId: 'insert',
* label: 'Insert',
* groups: [
* {
* groupId: 'insertMainWidgets',
* items: [
* 'menuBar:insertImage',
* 'menuBar:ckbox',
* 'menuBar:ckfinder',
* 'menuBar:insertTable'
* ]
* },
* {
* groupId: 'insertInline',
* items: [
* 'menuBar:link',
* 'menuBar:bookmark',
* 'menuBar:comment',
* 'menuBar:insertMergeField',
* 'menuBar:emoji'
* ]
* },
* {
* groupId: 'insertMinorWidgets',
* items: [
* 'menuBar:mediaEmbed',
* 'menuBar:insertTemplate',
* 'menuBar:blockQuote',
* 'menuBar:codeBlock',
* 'menuBar:htmlEmbed'
* ]
* },
* {
* groupId: 'insertStructureWidgets',
* items: [
* 'menuBar:horizontalLine',
* 'menuBar:pageBreak',
* 'menuBar:tableOfContents'
* ]
* },
* {
* groupId: 'restrictedEditing',
* items: [
* 'menuBar:restrictedEditing'
* ]
* }
* ]
* },
* {
* menuId: 'format',
* label: 'Format',
* groups: [
* {
* groupId: 'textAndFont',
* items: [
* {
* menuId: 'text',
* label: 'Text',
* groups: [
* {
* groupId: 'basicStyles',
* items: [
* 'menuBar:bold',
* 'menuBar:italic',
* 'menuBar:underline',
* 'menuBar:strikethrough',
* 'menuBar:superscript',
* 'menuBar:subscript',
* 'menuBar:code'
* ]
* },
* {
* groupId: 'textPartLanguage',
* items: [
* 'menuBar:textPartLanguage'
* ]
* }
* ]
* },
* {
* menuId: 'font',
* label: 'Font',
* groups: [
* {
* groupId: 'fontProperties',
* items: [
* 'menuBar:fontSize',
* 'menuBar:fontFamily'
* ]
* },
* {
* groupId: 'fontColors',
* items: [
* 'menuBar:fontColor',
* 'menuBar:fontBackgroundColor'
* ]
* },
* {
* groupId: 'highlight',
* items: [
* 'menuBar:highlight'
* ]
* }
* ]
* },
* 'menuBar:heading'
* ]
* },
* {
* groupId: 'list',
* items: [
* 'menuBar:bulletedList',
* 'menuBar:numberedList',
* 'menuBar:multiLevelList',
* 'menuBar:todoList'
* ]
* },
* {
* groupId: 'indent',
* items: [
* 'menuBar:alignment',
* 'menuBar:indent',
* 'menuBar:outdent'
* ]
* },
* {
* groupId: 'caseChange',
* items: [
* 'menuBar:caseChange'
* ]
* },
* {
* groupId: 'removeFormat',
* items: [
* 'menuBar:removeFormat'
* ]
* }
* ]
* },
* {
* menuId: 'tools',
* label: 'Tools',
* groups: [
* {
* groupId: 'aiTools',
* items: [
* 'menuBar:aiAssistant',
* 'menuBar:aiCommands'
* ]
* },
* {
* groupId: 'tools',
* items: [
* 'menuBar:trackChanges',
* 'menuBar:commentsArchive'
* ]
* }
* ]
* },
* {
* menuId: 'help',
* label: 'Help',
* groups: [
* {
* groupId: 'help',
* items: [
* 'menuBar:accessibilityHelp'
* ]
* }
* ]
* }
* ];
* ```
*
* The menu bar can be customized using the `config.menuBar.removeItems` and `config.menuBar.addItems` properties.
*/
// **NOTE: Whenever you make changes to this value, reflect it in the documentation above!**
export const DefaultMenuBarItems = [
{
menuId: 'file',
label: 'File',
groups: [
{
groupId: 'export',
items: [
'menuBar:exportPdf',
'menuBar:exportWord'
]
},
{
groupId: 'import',
items: [
'menuBar:importWord'
]
},
{
groupId: 'revisionHistory',
items: [
'menuBar:revisionHistory'
]
}
]
},
{
menuId: 'edit',
label: 'Edit',
groups: [
{
groupId: 'undo',
items: [
'menuBar:undo',
'menuBar:redo'
]
},
{
groupId: 'selectAll',
items: [
'menuBar:selectAll'
]
},
{
groupId: 'findAndReplace',
items: [
'menuBar:findAndReplace'
]
}
]
},
{
menuId: 'view',
label: 'View',
groups: [
{
groupId: 'sourceEditingEnhanced',
items: [
'menuBar:sourceEditingEnhanced'
]
},
{
groupId: 'sourceEditing',
items: [
'menuBar:sourceEditing'
]
},
{
groupId: 'showBlocks',
items: [
'menuBar:showBlocks'
]
},
{
groupId: 'previewMergeFields',
items: [
'menuBar:previewMergeFields'
]
},
{
groupId: 'fullscreen',
items: [
'menuBar:fullscreen'
]
},
{
groupId: 'restrictedEditing',
items: [
'menuBar:restrictedEditing'
]
}
]
},
{
menuId: 'insert',
label: 'Insert',
groups: [
{
groupId: 'insertMainWidgets',
items: [
'menuBar:insertImage',
'menuBar:ckbox',
'menuBar:ckfinder',
'menuBar:insertTable',
'menuBar:insertTableLayout'
]
},
{
groupId: 'insertInline',
items: [
'menuBar:link',
'menuBar:bookmark',
'menuBar:comment',
'menuBar:insertMergeField',
'menuBar:emoji'
]
},
{
groupId: 'insertMinorWidgets',
items: [
'menuBar:mediaEmbed',
'menuBar:insertTemplate',
'menuBar:specialCharacters',
'menuBar:blockQuote',
'menuBar:codeBlock',
'menuBar:htmlEmbed'
]
},
{
groupId: 'insertStructureWidgets',
items: [
'menuBar:horizontalLine',
'menuBar:pageBreak',
'menuBar:tableOfContents'
]
},
{
groupId: 'restrictedEditingException',
items: [
'menuBar:restrictedEditingException'
]
}
]
},
{
menuId: 'format',
label: 'Format',
groups: [
{
groupId: 'textAndFont',
items: [
{
menuId: 'text',
label: 'Text',
groups: [
{
groupId: 'basicStyles',
items: [
'menuBar:bold',
'menuBar:italic',
'menuBar:underline',
'menuBar:strikethrough',
'menuBar:superscript',
'menuBar:subscript',
'menuBar:code'
]
},
{
groupId: 'textPartLanguage',
items: [
'menuBar:textPartLanguage'
]
}
]
},
{
menuId: 'font',
label: 'Font',
groups: [
{
groupId: 'fontProperties',
items: [
'menuBar:fontSize',
'menuBar:fontFamily'
]
},
{
groupId: 'fontColors',
items: [
'menuBar:fontColor',
'menuBar:fontBackgroundColor'
]
},
{
groupId: 'highlight',
items: [
'menuBar:highlight'
]
}
]
},
'menuBar:heading'
]
},
{
groupId: 'list',
items: [
'menuBar:bulletedList',
'menuBar:numberedList',
'menuBar:multiLevelList',
'menuBar:todoList'
]
},
{
groupId: 'indent',
items: [
'menuBar:alignment',
'menuBar:indent',
'menuBar:outdent'
]
},
{
groupId: 'caseChange',
items: [
'menuBar:caseChange'
]
},
{
groupId: 'removeFormat',
items: [
'menuBar:removeFormat'
]
}
]
},
{
menuId: 'tools',
label: 'Tools',
groups: [
{
groupId: 'aiTools',
items: [
'menuBar:aiAssistant',
'menuBar:aiCommands'
]
},
{
groupId: 'tools',
items: [
'menuBar:trackChanges',
'menuBar:commentsArchive'
]
}
]
},
{
menuId: 'help',
label: 'Help',
groups: [
{
groupId: 'help',
items: [
'menuBar:accessibilityHelp'
]
}
]
}
];
/**
* Performs a cleanup and normalization of the menu bar configuration.
*/
export function normalizeMenuBarConfig(config) {
let configObject;
// The integrator specified the config as an object but without items. Let's give them defaults but respect their
// additions and removals.
if (!('items' in config) || !config.items) {
configObject = {
items: cloneDeep(DefaultMenuBarItems),
addItems: [],
removeItems: [],
isVisible: true,
isUsingDefaultConfig: true,
...config
};
}
// The integrator specified the config as an object and there are items there. Let's take it as it is.
else {
configObject = {
items: config.items,
removeItems: [],
addItems: [],
isVisible: true,
isUsingDefaultConfig: false,
...config
};
}
return configObject;
}
/**
* Processes a normalized menu bar config and returns a config clone with the following modifications:
*
* * Removed components that are not available in the component factory,
* * Removed obsolete separators,
* * Purged empty menus,
* * Localized top-level menu labels.
*/
export function processMenuBarConfig({ normalizedConfig, locale, componentFactory, extraItems }) {
const configClone = cloneDeep(normalizedConfig);
handleAdditions(normalizedConfig, configClone, extraItems);
handleRemovals(normalizedConfig, configClone);
handleAdditions(normalizedConfig, configClone, configClone.addItems);
purgeUnavailableComponents(normalizedConfig, configClone, componentFactory);
purgeEmptyMenus(normalizedConfig, configClone);
localizeMenuLabels(configClone, locale);
return configClone;
}
/**
* Removes items from the menu bar config based on user `removeItems` configuration. Users can remove
* individual items, groups, or entire menus. For each removed item, a warning is logged if the item
* was not found in the configuration.
*/
function handleRemovals(originalConfig, config) {
const itemsToBeRemoved = config.removeItems;
const successfullyRemovedItems = [];
// Remove top-level menus.
config.items = config.items.filter(({ menuId }) => {
if (itemsToBeRemoved.includes(menuId)) {
successfullyRemovedItems.push(menuId);
return false;
}
return true;
});
walkConfigMenus(config.items, menuDefinition => {
// Remove groups from menus.
menuDefinition.groups = menuDefinition.groups.filter(({ groupId }) => {
if (itemsToBeRemoved.includes(groupId)) {
successfullyRemovedItems.push(groupId);
return false;
}
return true;
});
// Remove sub-menus and items from groups.
for (const groupDefinition of menuDefinition.groups) {
groupDefinition.items = groupDefinition.items.filter(item => {
const itemId = getIdFromGroupItem(item);
if (itemsToBeRemoved.includes(itemId)) {
successfullyRemovedItems.push(itemId);
return false;
}
return true;
});
}
});
for (const itemName of itemsToBeRemoved) {
if (!successfullyRemovedItems.includes(itemName)) {
/**
* There was a problem processing the configuration of the menu bar. The item with the given
* name does could not be removed from the menu bar configuration.
*
* This warning usually shows up when the {@link module:core/plugin~Plugin} which is supposed
* to provide a menu bar item has not been loaded or there is a typo in the
* {@link module:core/editor/editorconfig~EditorConfig#menuBar menu bar configuration}.
*
* @error menu-bar-item-could-not-be-removed
* @param menuBarConfig The full configuration of the menu bar.
* @param itemName The name of the item that was not removed from the menu bar.
*/
logWarning('menu-bar-item-could-not-be-removed', {
menuBarConfig: originalConfig,
itemName
});
}
}
}
/**
* Adds provided items to config. It allows for adding menus, groups, and items at arbitrary
* positions in the menu bar. If the position does not exist, a warning is logged.
*/
function handleAdditions(originalConfig, config, items) {
const successFullyAddedItems = [];
if (items.length == 0) {
return;
}
for (const itemToAdd of items) {
const relation = getRelationFromPosition(itemToAdd.position);
const relativeId = getRelativeIdFromPosition(itemToAdd.position);
// Adding a menu.
if (isMenuBarMenuAddition(itemToAdd)) {
if (!relativeId) {
// Adding a top-level menu at the beginning of the menu bar.
if (relation === 'start') {
config.items.unshift(itemToAdd.menu);
successFullyAddedItems.push(itemToAdd);
}
// Adding a top-level menu at the end of the menu bar.
else if (relation === 'end') {
config.items.push(itemToAdd.menu);
successFullyAddedItems.push(itemToAdd);
}
}
else {
const topLevelMenuDefinitionIndex = config.items.findIndex(menuDefinition => menuDefinition.menuId === relativeId);
// Adding a top-level menu somewhere between existing menu bar menus.
if (topLevelMenuDefinitionIndex != -1) {
if (relation === 'before') {
config.items.splice(topLevelMenuDefinitionIndex, 0, itemToAdd.menu);
successFullyAddedItems.push(itemToAdd);
}
else if (relation === 'after') {
config.items.splice(topLevelMenuDefinitionIndex + 1, 0, itemToAdd.menu);
successFullyAddedItems.push(itemToAdd);
}
}
// Adding a sub-menu to an existing items group.
else {
const wasAdded = addMenuOrItemToGroup(config, itemToAdd.menu, relativeId, relation);
if (wasAdded) {
successFullyAddedItems.push(itemToAdd);
}
}
}
}
// Adding a group.
else if (isMenuBarMenuGroupAddition(itemToAdd)) {
walkConfigMenus(config.items, menuDefinition => {
if (menuDefinition.menuId === relativeId) {
// Add a group at the start of a menu.
if (relation === 'start') {
menuDefinition.groups.unshift(itemToAdd.group);
successFullyAddedItems.push(itemToAdd);
}
// Add a group at the end of a menu.
else if (relation === 'end') {
menuDefinition.groups.push(itemToAdd.group);
successFullyAddedItems.push(itemToAdd);
}
}
else {
const relativeGroupIndex = menuDefinition.groups.findIndex(group => group.groupId === relativeId);
if (relativeGroupIndex !== -1) {
// Add a group before an existing group in a menu.
if (relation === 'before') {
menuDefinition.groups.splice(relativeGroupIndex, 0, itemToAdd.group);
successFullyAddedItems.push(itemToAdd);
}
// Add a group after an existing group in a menu.
else if (relation === 'after') {
menuDefinition.groups.splice(relativeGroupIndex + 1, 0, itemToAdd.group);
successFullyAddedItems.push(itemToAdd);
}
}
}
});
}
// Adding an item to an existing items group.
else {
const wasAdded = addMenuOrItemToGroup(config, itemToAdd.item, relativeId, relation);
if (wasAdded) {
successFullyAddedItems.push(itemToAdd);
}
}
}
for (const addedItemConfig of items) {
if (!successFullyAddedItems.includes(addedItemConfig)) {
/**
* There was a problem processing the configuration of the menu bar. The configured item could not be added
* because the position it was supposed to be added to does not exist.
*
* This warning usually shows up when the {@link module:core/plugin~Plugin} which is supposed
* to provide a menu bar item has not been loaded or there is a typo in the
* {@link module:core/editor/editorconfig~EditorConfig#menuBar menu bar configuration}.
*
* @error menu-bar-item-could-not-be-removed
* @param {object} menuBarConfig The full configuration of the menu bar.
* @param {object} addedItemConfig The name of the item that was not removed from the menu bar.
*/
logWarning('menu-bar-item-could-not-be-added', {
menuBarConfig: originalConfig,
addedItemConfig
});
}
}
}
/**
* Handles adding a sub-menu or an item into a group. The logic is the same for both cases.
*/
function addMenuOrItemToGroup(config, itemOrMenuToAdd, relativeId, relation) {
let wasAdded = false;
walkConfigMenus(config.items, menuDefinition => {
for (const { groupId, items: groupItems } of menuDefinition.groups) {
// Avoid infinite loops.
if (wasAdded) {
return;
}
if (groupId === relativeId) {
// Adding an item/menu at the beginning of a group.
if (relation === 'start') {
groupItems.unshift(itemOrMenuToAdd);
wasAdded = true;
}
// Adding an item/menu at the end of a group.
else if (relation === 'end') {
groupItems.push(itemOrMenuToAdd);
wasAdded = true;
}
}
else {
// Adding an item/menu relative to an existing item/menu.
const relativeItemIndex = groupItems.findIndex(groupItem => {
return getIdFromGroupItem(groupItem) === relativeId;
});
if (relativeItemIndex !== -1) {
if (relation === 'before') {
groupItems.splice(relativeItemIndex, 0, itemOrMenuToAdd);
wasAdded = true;
}
else if (relation === 'after') {
groupItems.splice(relativeItemIndex + 1, 0, itemOrMenuToAdd);
wasAdded = true;
}
}
}
}
});
return wasAdded;
}
/**
* Removes components from the menu bar configuration that are not available in the factory and would
* not be instantiated. Warns about missing components if the menu bar configuration was specified by the user.
*/
function purgeUnavailableComponents(originalConfig, config, componentFactory) {
walkConfigMenus(config.items, menuDefinition => {
for (const groupDefinition of menuDefinition.groups) {
groupDefinition.items = groupDefinition.items.filter(item => {
const isItemUnavailable = typeof item === 'string' && !componentFactory.has(item);
// The default configuration contains all possible editor features. But integrators' editors rarely load
// every possible feature. This is why we do not want to log warnings about unavailable items for the default config
// because they would show up in almost every integration. If the configuration has been provided by
// the integrator, on the other hand, then these warnings bring value.
if (isItemUnavailable && !config.isUsingDefaultConfig) {
/**
* There was a problem processing the configuration of the menu bar. The item with the given
* name does not exist so it was omitted when rendering the menu bar.
*
* This warning usually shows up when the {@link module:core/plugin~Plugin} which is supposed
* to provide a menu bar item has not been loaded or there is a typo in the
* {@link module:core/editor/editorconfig~EditorConfig#menuBar menu bar configuration}.
*
* Make sure the plugin responsible for this menu bar item is loaded and the menu bar configuration
* is correct, e.g. {@link module:basic-styles/bold/boldui~BoldUI} is loaded for the `'menuBar:bold'`
* menu bar item.
*
* @error menu-bar-item-unavailable
* @param {object} menuBarConfig The full configuration of the menu bar.
* @param {object} parentMenuConfig The config of the menu the unavailable component was defined in.
* @param {string} componentName The name of the unavailable component.
*/
logWarning('menu-bar-item-unavailable', {
menuBarConfig: originalConfig,
parentMenuConfig: cloneDeep(menuDefinition),
componentName: item
});
}
return !isItemUnavailable;
});
}
});
}
/**
* Removes empty menus from the menu bar configuration to improve the visual UX. Such menus can occur
* when some plugins responsible for providing menu bar items have not been loaded and some part of
* the configuration populated menus using these components exclusively.
*/
function purgeEmptyMenus(originalConfig, config) {
const isUsingDefaultConfig = config.isUsingDefaultConfig;
let wasSubMenuPurged = false;
// Purge top-level menus.
config.items = config.items.filter(menuDefinition => {
if (!menuDefinition.groups.length) {
warnAboutEmptyMenu(originalConfig, menuDefinition, isUsingDefaultConfig);
return false;
}
return true;
});
// Warn if there were no top-level menus left in the menu bar after purging.
if (!config.items.length) {
warnAboutEmptyMenu(originalConfig, originalConfig, isUsingDefaultConfig);
return;
}
// Purge sub-menus and groups.
walkConfigMenus(config.items, menuDefinition => {
// Get rid of empty groups.
menuDefinition.groups = menuDefinition.groups.filter(groupDefinition => {
if (!groupDefinition.items.length) {
wasSubMenuPurged = true;
return false;
}
return true;
});
// Get rid of empty sub-menus.
for (const groupDefinition of menuDefinition.groups) {
groupDefinition.items = groupDefinition.items.filter(item => {
// If no groups were left after removing empty ones.
if (isMenuDefinition(item) && !item.groups.length) {
warnAboutEmptyMenu(originalConfig, item, isUsingDefaultConfig);
wasSubMenuPurged = true;
return false;
}
return true;
});
}
});
if (wasSubMenuPurged) {
// The config is walked from the root to the leaves so if anything gets removed, we need to re-run the
// whole process because it could've affected parents.
purgeEmptyMenus(originalConfig, config);
}
}
function warnAboutEmptyMenu(originalConfig, emptyMenuConfig, isUsingDefaultConfig) {
if (isUsingDefaultConfig) {
return;
}
/**
* There was a problem processing the configuration of the menu bar. One of the menus
* is empty so it was omitted when rendering the menu bar.
*
* This warning usually shows up when some {@link module:core/plugin~Plugin plugins} responsible for
* providing menu bar items have not been loaded and the
* {@link module:core/editor/editorconfig~EditorConfig#menuBar menu bar configuration} was not updated.
*
* Make sure all necessary editor plugins are loaded and/or update the menu bar configuration
* to account for the missing menu items.
*
* @error menu-bar-menu-empty
* @param {object} menuBarConfig The full configuration of the menu bar.
* @param {object} emptyMenuConfig The definition of the menu that has no child items.
*/
logWarning('menu-bar-menu-empty', {
menuBarConfig: originalConfig,
emptyMenuConfig
});
}
/**
* Localizes the user-config using pre-defined localized category labels.
*/
function localizeMenuLabels(config, locale) {
const t = locale.t;
const localizedCategoryLabels = {
// Top-level categories.
'File': t({
string: 'File',
id: 'MENU_BAR_MENU_FILE'
}),
'Edit': t({
string: 'Edit',
id: 'MENU_BAR_MENU_EDIT'
}),
'View': t({
string: 'View',
id: 'MENU_BAR_MENU_VIEW'
}),
'Insert': t({
string: 'Insert',
id: 'MENU_BAR_MENU_INSERT'
}),
'Format': t({
string: 'Format',
id: 'MENU_BAR_MENU_FORMAT'
}),
'Tools': t({
string: 'Tools',
id: 'MENU_BAR_MENU_TOOLS'
}),
'Help': t({
string: 'Help',
id: 'MENU_BAR_MENU_HELP'
}),
// Sub-menus.
'Text': t({
string: 'Text',
id: 'MENU_BAR_MENU_TEXT'
}),
'Font': t({
string: 'Font',
id: 'MENU_BAR_MENU_FONT'
})
};
walkConfigMenus(config.items, definition => {
if (definition.label in localizedCategoryLabels) {
definition.label = localizedCategoryLabels[definition.label];
}
});
}
/**
* Recursively visits all menu definitions in the config and calls the callback for each of them.
*/
function walkConfigMenus(definition, callback) {
if (Array.isArray(definition)) {
for (const topLevelMenuDefinition of definition) {
walk(topLevelMenuDefinition);
}
}
function walk(menuDefinition) {
callback(menuDefinition);
for (const groupDefinition of menuDefinition.groups) {
for (const groupItem of groupDefinition.items) {
if (isMenuDefinition(groupItem)) {
walk(groupItem);
}
}
}
}
}
function isMenuBarMenuAddition(definition) {
return typeof definition === 'object' && 'menu' in definition;
}
function isMenuBarMenuGroupAddition(definition) {
return typeof definition === 'object' && 'group' in definition;
}
function getRelationFromPosition(position) {
if (position.startsWith('start')) {
return 'start';
}
else if (position.startsWith('end')) {
return 'end';
}
else if (position.startsWith('after')) {
return 'after';
}
else {
return 'before';
}
}
function getRelativeIdFromPosition(position) {
const match = position.match(/^[^:]+:(.+)/);
if (match) {
return match[1];
}
return null;
}
function getIdFromGroupItem(item) {
return typeof item === 'string' ? item : item.menuId;
}
function isMenuDefinition(definition) {
return typeof definition === 'object' && 'menuId' in definition;
}