app/src/commons/model/app-menu.ts
crusader 7c915f6d5f ing
2018-08-17 15:11:55 +09:00

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);
}
}