666 lines
20 KiB
TypeScript
666 lines
20 KiB
TypeScript
/** A type union of all possible types of menu items */
|
|
export type MenuItem =
|
|
| IMenuItem
|
|
| ISubmenuItem
|
|
| ISeparatorMenuItem
|
|
| ICheckboxMenuItem
|
|
| IRadioMenuItem;
|
|
|
|
/** A type union of all types of menu items which can be executed */
|
|
export type ExecutableMenuItem = IMenuItem | ICheckboxMenuItem | IRadioMenuItem;
|
|
|
|
/**
|
|
* Common properties for all item types except separator.
|
|
* Only useful for declaring the types, not for consumption
|
|
*/
|
|
interface IBaseMenuItem {
|
|
readonly id: string;
|
|
readonly enabled: boolean;
|
|
readonly visible: boolean;
|
|
readonly label: string;
|
|
}
|
|
|
|
/**
|
|
* An interface describing the properties of a 'normal'
|
|
* menu item, i.e. a clickable item with a label but no
|
|
* other special properties.
|
|
*/
|
|
export interface IMenuItem extends IBaseMenuItem {
|
|
readonly type: 'menuItem';
|
|
readonly accelerator: string | null;
|
|
readonly accessKey: string | null;
|
|
}
|
|
|
|
/**
|
|
* An interface describing the properties of a
|
|
* submenu menu item, i.e. an item which has an associated
|
|
* submenu which can be expanded to reveal more menu
|
|
* item. Not in itself executable, only a container.
|
|
*/
|
|
export interface ISubmenuItem extends IBaseMenuItem {
|
|
readonly type: 'submenuItem';
|
|
readonly menu: IMenu;
|
|
readonly accessKey: string | null;
|
|
}
|
|
|
|
/**
|
|
* An interface describing the properties of a checkbox
|
|
* menu item, i.e. an item which has an associated checked
|
|
* state that can be toggled by executing it.
|
|
*/
|
|
export interface ICheckboxMenuItem extends IBaseMenuItem {
|
|
readonly type: 'checkbox';
|
|
readonly accelerator: string | null;
|
|
readonly accessKey: string | null;
|
|
readonly checked: boolean;
|
|
}
|
|
|
|
/**
|
|
* An interface describing the properties of a checkbox
|
|
* menu item, i.e. an item which has an associated checked
|
|
* state that is checked or unchecked based on application logic.
|
|
*
|
|
* The radio menu item is probably going to be used in a collection
|
|
* of more radio menu items where the checked item is assigned
|
|
* based on the last executed item in that group.
|
|
*/
|
|
export interface IRadioMenuItem extends IBaseMenuItem {
|
|
readonly type: 'radio';
|
|
readonly accelerator: string | null;
|
|
readonly accessKey: string | null;
|
|
readonly checked: boolean;
|
|
}
|
|
|
|
/**
|
|
* An interface describing the properties of a separator menu
|
|
* item, i.e. an item which sole purpose is to create separation
|
|
* between menu items. It has no other semantics and is purely
|
|
* a visual hint.
|
|
*/
|
|
export interface ISeparatorMenuItem {
|
|
readonly id: string;
|
|
readonly type: 'separator';
|
|
readonly visible: boolean;
|
|
}
|
|
|
|
/**
|
|
* An interface describing a menu.
|
|
*
|
|
* Holds collection of menu items and an indication of which item (if any)
|
|
* in the menu is selected.
|
|
*/
|
|
export interface IMenu {
|
|
/**
|
|
* The id of this menu. For the root menu this will be undefined. For all
|
|
* other menus it will be the same as the id of the submenu item which
|
|
* owns this menu.
|
|
*
|
|
* +---------------------------+
|
|
* | Root menu (id: undefined) |
|
|
* +---------------------------+ +--------------------------+
|
|
* | File (id File) +--> File menu (id: File) |
|
|
* +---------------------------+ +--------------------------+
|
|
* | Edit (id Edit) | | Open (id File.Open) |
|
|
* +---------------------------+ +--------------------------+
|
|
* | Close (id File.Close) |
|
|
* +--------------------------+
|
|
*/
|
|
readonly id?: string;
|
|
|
|
/** Type identifier, used for type narrowing */
|
|
readonly type: 'menu';
|
|
|
|
/** A collection of zero or more menu items */
|
|
readonly items: ReadonlyArray<MenuItem>;
|
|
|
|
/** The selected item in the menu or undefined if no item is selected */
|
|
readonly selectedItem?: MenuItem;
|
|
}
|
|
|
|
/**
|
|
* Gets the accelerator for a given menu item. If the menu item doesn't
|
|
* have an explicitly defined accelerator but does have a defined role
|
|
* the default accelerator (if any) for that particular role will be
|
|
* returned.
|
|
*/
|
|
function getAccelerator(menuItem: Electron.MenuItem): string | null {
|
|
if (menuItem.accelerator) {
|
|
return menuItem.accelerator as string;
|
|
}
|
|
|
|
if (menuItem.role) {
|
|
const unsafeItem = menuItem as any;
|
|
// https://github.com/electron/electron/blob/d4a8a64ba/lib/browser/api/menu-item.js#L62
|
|
const getDefaultRoleAccelerator = unsafeItem.getDefaultRoleAccelerator;
|
|
|
|
if (typeof getDefaultRoleAccelerator === 'function') {
|
|
try {
|
|
const defaultRoleAccelerator = getDefaultRoleAccelerator.call(menuItem);
|
|
if (typeof defaultRoleAccelerator === 'string') {
|
|
return defaultRoleAccelerator;
|
|
}
|
|
} catch (err) {
|
|
console.error('Could not retrieve default accelerator', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return the access key (applicable on Windows) from a menu item label.
|
|
*
|
|
* An access key is a letter or symbol preceded by an ampersand, i.e. in
|
|
* the string "Check for &updates" the access key is 'u'. Access keys are
|
|
* case insensitive and are unique per menu.
|
|
*/
|
|
function getAccessKey(text: string): string | null {
|
|
const m = text.match(/&([^&])/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Creates an instance of one of the types in the MenuItem type union based
|
|
* on an Electron MenuItem instance. Will recurse through all sub menus and
|
|
* convert each item.
|
|
*/
|
|
function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem {
|
|
// Our menu items always have ids and Electron.MenuItem takes on whatever
|
|
// properties was defined on the MenuItemOptions template used to create it
|
|
// but doesn't surface those in the type declaration.
|
|
const id: string | undefined = (menuItem as any).id;
|
|
if (!id) {
|
|
throw new Error(`menuItem must specify id: ${menuItem.label}`);
|
|
}
|
|
const enabled = menuItem.enabled;
|
|
const visible = menuItem.visible;
|
|
const label = menuItem.label;
|
|
const checked = menuItem.checked;
|
|
const accelerator = getAccelerator(menuItem);
|
|
const accessKey = getAccessKey(menuItem.label);
|
|
|
|
// normal, separator, submenu, checkbox or radio.
|
|
switch (menuItem.type) {
|
|
case 'normal':
|
|
return {
|
|
id,
|
|
type: 'menuItem',
|
|
label,
|
|
enabled,
|
|
visible,
|
|
accelerator,
|
|
accessKey,
|
|
};
|
|
case 'separator':
|
|
return { id, type: 'separator', visible };
|
|
case 'submenu':
|
|
const menu = menuFromElectronMenu(menuItem.submenu as Electron.Menu, id);
|
|
return {
|
|
id,
|
|
type: 'submenuItem',
|
|
label,
|
|
enabled,
|
|
visible,
|
|
menu,
|
|
accessKey,
|
|
};
|
|
case 'checkbox':
|
|
return {
|
|
id,
|
|
type: 'checkbox',
|
|
label,
|
|
enabled,
|
|
visible,
|
|
accelerator,
|
|
checked,
|
|
accessKey,
|
|
};
|
|
case 'radio':
|
|
return {
|
|
id,
|
|
type: 'radio',
|
|
label,
|
|
enabled,
|
|
visible,
|
|
accelerator,
|
|
checked,
|
|
accessKey,
|
|
};
|
|
default:
|
|
// return assertNever(
|
|
// menuItem.type,
|
|
// `Unknown menu item type ${menuItem.type}`
|
|
// );
|
|
}
|
|
}
|
|
/**
|
|
* Creates a IMenu instance based on an Electron Menu instance.
|
|
* Will recurse through all sub menus and convert each item using
|
|
* menuItemFromElectronMenuItem.
|
|
*
|
|
* @param menu - The electron menu instance to convert into an
|
|
* IMenu instance
|
|
*
|
|
* @param id - The id of the menu. Menus share their id with
|
|
* their parent item. The root menu id is undefined.
|
|
*/
|
|
export function menuFromElectronMenu(menu: Electron.Menu, id?: string): IMenu {
|
|
const items = menu.items.map(menuItemFromElectronMenuItem);
|
|
|
|
if (__DEV__) {
|
|
const seenAccessKeys = new Set<string>();
|
|
|
|
for (const item of items) {
|
|
if (item.visible) {
|
|
if (itemMayHaveAccessKey(item) && item.accessKey) {
|
|
if (seenAccessKeys.has(item.accessKey.toLowerCase())) {
|
|
throw new Error(
|
|
`Duplicate access key '${item.accessKey}' for item ${item.label}`
|
|
);
|
|
} else {
|
|
seenAccessKeys.add(item.accessKey.toLowerCase());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { id, type: 'menu', items };
|
|
}
|
|
|
|
/**
|
|
* Creates a map between MenuItem ids and MenuItems by recursing
|
|
* through all items and all submenus.
|
|
*/
|
|
function buildIdMap(
|
|
menu: IMenu,
|
|
map = new Map<string, MenuItem>()
|
|
): Map<string, MenuItem> {
|
|
for (const item of menu.items) {
|
|
map.set(item.id, item);
|
|
if (item.type === 'submenuItem') {
|
|
buildIdMap(item.menu, map);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
/** Type guard which narrows a MenuItem to one which supports access keys */
|
|
export function itemMayHaveAccessKey(
|
|
item: MenuItem
|
|
): item is IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem {
|
|
return (
|
|
item.type === 'menuItem' ||
|
|
item.type === 'submenuItem' ||
|
|
item.type === 'checkbox' ||
|
|
item.type === 'radio'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a value indicating whether or not the given menu item can be
|
|
* selected. Selectable items are non-separator items which are enabled
|
|
* and visible.
|
|
*/
|
|
export function itemIsSelectable(item: MenuItem) {
|
|
return item.type !== 'separator' && item.enabled && item.visible;
|
|
}
|
|
|
|
/**
|
|
* Attempts to locate a menu item matching the provided access key in a
|
|
* given list of items. The access key comparison is case-insensitive.
|
|
*
|
|
* Note that this function does not take into account whether or not the
|
|
* item is selectable, consumers of this function need to perform that
|
|
* check themselves when applicable.
|
|
*/
|
|
export function findItemByAccessKey(
|
|
accessKey: string,
|
|
items: ReadonlyArray<MenuItem>
|
|
): IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem | null {
|
|
const lowerCaseAccessKey = accessKey.toLowerCase();
|
|
|
|
for (const item of items) {
|
|
if (itemMayHaveAccessKey(item)) {
|
|
if (
|
|
item.accessKey &&
|
|
item.accessKey.toLowerCase() === lowerCaseAccessKey
|
|
) {
|
|
return item;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* An immutable, transformable object which represents an application menu
|
|
* and its current state (which menus are open, which items are selected).
|
|
*
|
|
* The primary use case for this is for rendering a custom application menu
|
|
* on non-macOS systems. As such some interactions are explicitly made to
|
|
* conform to Windows menu interactions. This includes things like selecting
|
|
* the entire path up until the last selected item. This is necessary since,
|
|
* on Windows, the parent menu item of a menu might not be selected even
|
|
* though the submenu is. This is in order to allow for some delay when
|
|
* moving the cursor from one menu pane to another.
|
|
*
|
|
* In general, however, this object is not platform specific and much of
|
|
* the interactions are defined by the component using it.
|
|
*/
|
|
export class AppMenu {
|
|
/**
|
|
* A list of currently open menus with their selected items
|
|
* in the application menu.
|
|
*
|
|
* The semantics around what constitutes an open menu and how
|
|
* selection works is defined within this class class as well as
|
|
* in the individual components transforming that state.
|
|
*/
|
|
public readonly openMenus: ReadonlyArray<IMenu>;
|
|
|
|
/**
|
|
* The menu that this instance operates on, taken from an
|
|
* electron Menu instance and converted into an IMenu model
|
|
* by menuFromElectronMenu.
|
|
*/
|
|
private readonly menu: IMenu;
|
|
|
|
/**
|
|
* A map between menu item ids and their corresponding MenuItem
|
|
*/
|
|
private readonly menuItemById: Map<string, MenuItem>;
|
|
|
|
/**
|
|
* Static constructor for the initial creation of an AppMenu instance
|
|
* from an IMenu instance.
|
|
*/
|
|
public static fromMenu(menu: IMenu): AppMenu {
|
|
const map = buildIdMap(menu);
|
|
const openMenus = [menu];
|
|
|
|
return new AppMenu(menu, openMenus, map);
|
|
}
|
|
|
|
// Used by static constructors and transformers.
|
|
private constructor(
|
|
menu: IMenu,
|
|
openMenus: ReadonlyArray<IMenu>,
|
|
menuItemById: Map<string, MenuItem>
|
|
) {
|
|
this.menu = menu;
|
|
this.openMenus = openMenus;
|
|
this.menuItemById = menuItemById;
|
|
}
|
|
|
|
/**
|
|
* Retrieves a menu item by its id.
|
|
*/
|
|
public getItemById(id: string): MenuItem | undefined {
|
|
return this.menuItemById.get(id);
|
|
}
|
|
|
|
/**
|
|
* Merges the current AppMenu state with a new menu while
|
|
* attempting to maintain selection state.
|
|
*/
|
|
public withMenu(newMenu: IMenu): AppMenu {
|
|
const newMap = buildIdMap(newMenu);
|
|
const newOpenMenus = new Array<IMenu>();
|
|
|
|
// Enumerate all currently open menus and attempt to recreate
|
|
// the openMenus array with the new menu instances
|
|
for (const openMenu of this.openMenus) {
|
|
let newOpenMenu: IMenu;
|
|
|
|
// No id means it's the root menu, simple enough.
|
|
if (!openMenu.id) {
|
|
newOpenMenu = newMenu;
|
|
} else {
|
|
// Menus share id with their parent item
|
|
const item = newMap.get(openMenu.id);
|
|
|
|
if (item && item.type === 'submenuItem') {
|
|
newOpenMenu = item.menu;
|
|
} else {
|
|
// This particular menu can't be found in the new menu
|
|
// structure, we have no choice but to bail here and
|
|
// not open this particular menu.
|
|
break;
|
|
}
|
|
}
|
|
|
|
let newSelectedItem: MenuItem | undefined;
|
|
|
|
if (openMenu.selectedItem) {
|
|
newSelectedItem = newMap.get(openMenu.selectedItem.id);
|
|
}
|
|
|
|
newOpenMenus.push({
|
|
id: newOpenMenu.id,
|
|
type: 'menu',
|
|
items: newOpenMenu.items,
|
|
selectedItem: newSelectedItem,
|
|
});
|
|
}
|
|
|
|
return new AppMenu(newMenu, newOpenMenus, newMap);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance with the given submenu open.
|
|
*
|
|
* @param submenuItem - The item which submenu should be appended
|
|
* to the list of open menus.
|
|
*
|
|
* @param selectFirstItem - A convenience item for automatically selecting
|
|
* the first item in the newly opened menu.
|
|
*
|
|
* If false the new menu is opened without a selection.
|
|
*
|
|
* Defaults to false.
|
|
*/
|
|
public withOpenedMenu(
|
|
submenuItem: ISubmenuItem,
|
|
selectFirstItem = false
|
|
): AppMenu {
|
|
const ourMenuItem = this.menuItemById.get(submenuItem.id);
|
|
|
|
if (!ourMenuItem) {
|
|
return this;
|
|
}
|
|
|
|
if (ourMenuItem.type !== 'submenuItem') {
|
|
throw new Error(
|
|
`Attempt to open a submenu from an item of wrong type: ${
|
|
ourMenuItem.type
|
|
}`
|
|
);
|
|
}
|
|
|
|
const parentMenuIndex = this.openMenus.findIndex(
|
|
m => m.items.indexOf(ourMenuItem) !== -1
|
|
);
|
|
|
|
// The parent menu has apparently been closed in between, we could go and
|
|
// recreate it but it's probably not worth it.
|
|
if (parentMenuIndex === -1) {
|
|
return this;
|
|
}
|
|
|
|
const newOpenMenus = this.openMenus.slice(0, parentMenuIndex + 1);
|
|
|
|
if (selectFirstItem) {
|
|
// First selectable item.
|
|
const selectedItem = ourMenuItem.menu.items.find(itemIsSelectable);
|
|
newOpenMenus.push({ ...ourMenuItem.menu, selectedItem });
|
|
} else {
|
|
newOpenMenus.push(ourMenuItem.menu);
|
|
}
|
|
|
|
return new AppMenu(this.menu, newOpenMenus, this.menuItemById);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance with the given menu removed from
|
|
* the list of open menus.
|
|
*
|
|
* @param menu - The menu which is to be closed, i.e. removed from the
|
|
* list of open menus.
|
|
*/
|
|
public withClosedMenu(menu: IMenu) {
|
|
// Root menu is always open and can't be closed
|
|
if (!menu.id) {
|
|
return this;
|
|
}
|
|
|
|
const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id);
|
|
|
|
if (ourMenuIndex === -1) {
|
|
return this;
|
|
}
|
|
|
|
const newOpenMenus = this.openMenus.slice(0, ourMenuIndex);
|
|
|
|
return new AppMenu(this.menu, newOpenMenus, this.menuItemById);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance with the list of open menus trimmed
|
|
* to not include any menus below the given menu.
|
|
*
|
|
* @param menu - The last menu which is to remain in the list of open
|
|
* menus, all menus below this level will be pruned from
|
|
* the list of open menus.
|
|
*/
|
|
public withLastMenu(menu: IMenu) {
|
|
const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id);
|
|
|
|
if (ourMenuIndex === -1) {
|
|
return this;
|
|
}
|
|
|
|
const newOpenMenus = this.openMenus.slice(0, ourMenuIndex + 1);
|
|
|
|
return new AppMenu(this.menu, newOpenMenus, this.menuItemById);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance in which the given menu item
|
|
* is selected.
|
|
*
|
|
* Additional semantics:
|
|
*
|
|
* All menus leading up to the given menu item will have their
|
|
* selection reset in such a fashion that the selection path
|
|
* points to the given menu item.
|
|
*
|
|
* All menus after the menu in which the given item resides
|
|
* will have their selections cleared.
|
|
*
|
|
* @param menuItem - The menu item which is to be selected.
|
|
*/
|
|
public withSelectedItem(menuItem: MenuItem) {
|
|
const ourMenuItem = this.menuItemById.get(menuItem.id);
|
|
|
|
// The item that someone is trying to select no longer
|
|
// exists, not much we can do about that.
|
|
if (!ourMenuItem) {
|
|
return this;
|
|
}
|
|
|
|
const parentMenuIndex = this.openMenus.findIndex(
|
|
m => m.items.indexOf(ourMenuItem) !== -1
|
|
);
|
|
|
|
// The menu which the selected item belongs to is no longer open,
|
|
// not much we can do about that.
|
|
if (parentMenuIndex === -1) {
|
|
return this;
|
|
}
|
|
|
|
const newOpenMenus = this.openMenus.slice();
|
|
|
|
const parentMenu = newOpenMenus[parentMenuIndex];
|
|
|
|
newOpenMenus[parentMenuIndex] = { ...parentMenu, selectedItem: ourMenuItem };
|
|
|
|
// All submenus below the active menu should have their selection cleared
|
|
for (let i = parentMenuIndex + 1; i < newOpenMenus.length; i++) {
|
|
newOpenMenus[i] = { ...newOpenMenus[i], selectedItem: undefined };
|
|
}
|
|
|
|
// Ensure that the path that lead us to the currently selected menu is
|
|
// selected. i.e. all menus above the currently active menu should have
|
|
// their selection reset to point to the currently active menu.
|
|
for (let i = parentMenuIndex - 1; i >= 0; i--) {
|
|
const menu = newOpenMenus[i];
|
|
const childMenu = newOpenMenus[i + 1];
|
|
|
|
const selectedItem = menu.items.find(
|
|
item => item.type === 'submenuItem' && item.id === childMenu.id
|
|
);
|
|
|
|
newOpenMenus[i] = { ...menu, selectedItem };
|
|
}
|
|
|
|
return new AppMenu(this.menu, newOpenMenus, this.menuItemById);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance in which the given menu has had
|
|
* its selection state cleared.
|
|
*
|
|
* Additional semantics:
|
|
*
|
|
* All menus leading up to the given menu item will have their
|
|
* selection reset in such a fashion that the selection path
|
|
* points to the given menu.
|
|
*
|
|
* @param menu - The menu which is to have its selection state
|
|
* cleared.
|
|
*/
|
|
public withDeselectedMenu(menu: IMenu) {
|
|
const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id);
|
|
|
|
// The menu that someone is trying to deselect is no longer open
|
|
// so no need to worry about selection
|
|
if (ourMenuIndex === -1) {
|
|
return this;
|
|
}
|
|
|
|
const ourMenu = this.openMenus[ourMenuIndex];
|
|
const newOpenMenus = this.openMenus.slice();
|
|
|
|
newOpenMenus[ourMenuIndex] = { ...ourMenu, selectedItem: undefined };
|
|
|
|
// Ensure that the path to the menu without an active selection is
|
|
// selected. i.e. all menus above should have their selection reset
|
|
// to point to the menu which no longer has an active selection.
|
|
for (let i = ourMenuIndex - 1; i >= 0; i--) {
|
|
const _menu = newOpenMenus[i];
|
|
const childMenu = newOpenMenus[i + 1];
|
|
|
|
const selectedItem = _menu.items.find(
|
|
item => item.type === 'submenuItem' && item.id === childMenu.id
|
|
);
|
|
|
|
newOpenMenus[i] = { ..._menu, selectedItem };
|
|
}
|
|
|
|
return new AppMenu(this.menu, newOpenMenus, this.menuItemById);
|
|
}
|
|
|
|
/**
|
|
* Creates a new copy of this AppMenu instance in which all state
|
|
* is reset. Resetting means that only the root menu is open and
|
|
* all selection state is cleared.
|
|
*/
|
|
public withReset() {
|
|
return new AppMenu(this.menu, [this.menu], this.menuItemById);
|
|
}
|
|
}
|