UNPKG

@difizen/mana-core

Version:

272 lines (250 loc) 8.76 kB
import type { MaybePromise } from '@difizen/mana-common'; import { Disposable } from '@difizen/mana-common'; import type { Contribution } from '@difizen/mana-syringe'; import { contrib, inject, singleton, Syringe } from '@difizen/mana-syringe'; import { ApplicationContribution } from '../application/application'; import { CommandRegistry } from '../command/command-registry'; import type { MenuOptions, MenuPath, ActionMenuNode, GeneralMenuItem, } from './menu-protocol'; import { CommandMenuNode, ExecutableMenuNode } from './menu-protocol'; import { GeneralMenuItemFactory, MenuNode, MenuItem, ActionMenuItemFactory, } from './menu-protocol'; export const MenuContribution = Syringe.defineToken('MenuContribution'); export type MenuContribution = { /** * Register menus. * @param menus the menu registry. */ registerMenus: (menus: MenuRegistry) => void; }; /** * Register and unregister menus, submenus and actions */ @singleton({ contrib: ApplicationContribution }) export class MenuRegistry implements ApplicationContribution { protected readonly root: GeneralMenuItem; protected readonly contributions: Contribution.Provider<MenuContribution>; protected readonly generalItemFactory: GeneralMenuItemFactory; protected readonly actionItemFactory: ActionMenuItemFactory; protected readonly commands: CommandRegistry; constructor( @contrib(MenuContribution) contributions: Contribution.Provider<MenuContribution>, @inject(GeneralMenuItemFactory) generalItemFactory: GeneralMenuItemFactory, @inject(ActionMenuItemFactory) actionItemFactory: ActionMenuItemFactory, @inject(CommandRegistry) commands: CommandRegistry, ) { this.contributions = contributions; this.generalItemFactory = generalItemFactory; this.actionItemFactory = actionItemFactory; this.commands = commands; this.root = generalItemFactory({ id: '' }, []); } onStart(): void { for (const contribution of this.contributions.getContributions()) { contribution.registerMenus(this); } } /** * Adds the given menu action to the menu denoted by the given path. * * @returns a disposable which, when called, will remove the menu action again. */ registerMenuAction(menuPath: MenuPath, item: ActionMenuNode): Disposable { const menuNode = this.actionItemFactory(item, menuPath); const parent = this.getOrCreateGroup(menuPath); return parent.addNode(menuNode); } /** * Adds the given menu node to the menu denoted by the given path. * * @returns a disposable which, when called, will remove the menu node again. */ registerMenuNode(menuPath: MenuPath, item: MenuNode): Disposable { const menuNode = this.generalItemFactory(item, menuPath); const parent = this.getOrCreateGroup(menuPath); return parent.addNode(menuNode); } registerGroupMenu(menuPath: MenuPath, options: MenuOptions = {}): Disposable { if (menuPath.length === 0) { throw new Error('The group menu path cannot be empty.'); } const index = menuPath.length - 1; const menuId = menuPath[index]; const groupPath = index === 0 ? [] : menuPath.slice(0, index); const parent = this.getOrCreateGroup(groupPath, options); let groupNode = this.getOrCreateSub(parent, menuId, options); if (!groupNode) { groupNode = this.generalItemFactory({ id: menuId, ...options }, parent.path); return parent.addNode(groupNode); } if (options.label) { groupNode.label = options.label; } if (options.icon) { groupNode.icon = options.icon; } if (options.order) { groupNode.order = options.order; } return Disposable.NONE; } /** * Register a new menu at the given path with the given label. * (If the menu already exists without a label, iconClass or order this method can be used to set them.) * * @param menuPath the path for which a new submenu shall be registered. * @param label the label to be used for the new submenu. * @param options optionally allows to set an icon class and specify the order of the new menu. * * @returns if the menu was successfully created a disposable will be returned which, * when called, will remove the menu again. If the menu already existed a no-op disposable * will be returned. * * Note that if the menu already existed and was registered with a different label an error * will be thrown. */ registerSubmenu(menuPath: MenuPath, options: MenuOptions = {}): Disposable { if (menuPath.length === 0) { throw new Error('The sub menu path cannot be empty.'); } const index = menuPath.length - 1; const menuId = menuPath[index]; const groupPath = index === 0 ? [] : menuPath.slice(0, index); const parent = this.getOrCreateGroup(groupPath, options); let groupNode = this.getOrCreateSub(parent, menuId, options); if (!groupNode) { groupNode = this.generalItemFactory({ id: menuId, ...options }, parent.path); return parent.addNode(groupNode); } if (options.label) { groupNode.label = options.label; } if (options.icon) { groupNode.icon = options.icon; } if (options.order) { groupNode.order = options.order; } return Disposable.NONE; } /** * Unregister all menu nodes with the same id as the given menu action. * * @param item the item whose id will be used. * @param menuPath if specified only nodes within the path will be unregistered. */ unregisterMenuAction(item: ActionMenuNode, menuPath?: MenuPath): void; /** * Unregister all menu nodes with the given id. * * @param id the id which shall be removed. * @param menuPath if specified only nodes within the path will be unregistered. */ // eslint-disable-next-line @typescript-eslint/unified-signatures unregisterMenuAction(id: string, menuPath?: MenuPath): void; unregisterMenuAction( itemOrCommandOrId: ActionMenuNode | string, menuPath?: MenuPath, ): void { const id = MenuNode.is(itemOrCommandOrId) ? itemOrCommandOrId.id : itemOrCommandOrId; if (menuPath) { const parent = this.getOrCreateGroup(menuPath); parent.removeNode(id); return; } this.unregisterMenuNode(id); } /** * Recurse all menus, removing any menus matching the `id`. * * @param id technical identifier of the `MenuNode`. */ unregisterMenuNode(id: string): void { const recurse = (root: GeneralMenuItem) => { root.children.forEach((node) => { if (MenuItem.isGeneralMenuItem(node)) { node.removeNode(id); recurse(node); } }); }; recurse(this.root); } protected getOrCreateGroup( menuPath: MenuPath, options?: MenuOptions, ): GeneralMenuItem { let currentMenu = this.root; for (const segment of menuPath) { currentMenu = this.getOrCreateSub(currentMenu, segment, options); } return currentMenu; } protected getOrCreateSub( current: GeneralMenuItem, menuId: string, options: MenuOptions = {}, ): GeneralMenuItem { const sub = current.children.find((e) => e.id === menuId); if (MenuItem.isGeneralMenuItem(sub)) { return sub; } if (sub) { throw new Error(`'${menuId}' is not a menu group.`); } const newSub = this.generalItemFactory({ id: menuId, ...options }, current.path); current.addNode(newSub); return newSub; } /** * Returns the menu at the given path. * * @param menuPath the path specifying the menu to return. If not given the empty path will be used. * * @returns the root menu when `menuPath` is empty. If `menuPath` is not empty the specified menu is * returned if it exists, otherwise an error is thrown. */ getMenu(menuPath: MenuPath = []): GeneralMenuItem { return this.getOrCreateGroup(menuPath); } isVisible(item: MenuItem, ...args: any[]): boolean { if (ExecutableMenuNode.is(item)) { if (item.isVisible) { return item.isVisible(...args); } } if (CommandMenuNode.is(item)) { return this.commands.isVisible(item.command, ...args); } return true; } isEnabled(item: MenuItem, ...args: any[]): boolean { if (ExecutableMenuNode.is(item)) { if (item.isEnabled) { return item.isEnabled(...args); } } if (CommandMenuNode.is(item)) { return this.commands.isVisible(item.command, ...args); } return true; } execute(item: MenuItem, ...args: any[]): MaybePromise<void> { if (ExecutableMenuNode.is(item)) { return item.execute(...args); } if (CommandMenuNode.is(item)) { return this.commands.executeCommand(item.command, ...args); } } }