UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

1,418 lines (1,417 loc) 49.5 kB
/** * @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; }