UNPKG

@laserware/hoverboard

Version:

Better context menus for Electron.

592 lines (579 loc) 16 kB
// src/renderer/ContextMenuItem.ts var idGenerator = /* @__PURE__ */ (() => { let value = 1; return { next: () => `menu-item-${(value++).toString()}` }; })(); var ContextMenuItem = class { constructor(options = {}, type = void 0) { this.id = options.id ?? idGenerator.next(); this.type = type; this.visible = options.visible; this.before = options.before; this.after = options.after; this.beforeGroupContaining = options.beforeGroupContaining; this.afterGroupContaining = options.afterGroupContaining; } /** * Converts the properties of this menu item to a template that can be sent * to the main process to build the menu. */ toTemplate() { const template = { id: this.id }; if (this.type !== void 0) { template.type = this.type; } if (this.visible !== void 0) { template.visible = this.visible; } if (this.before !== void 0) { template.before = this.before; } if (this.after !== void 0) { template.after = this.after; } if (this.beforeGroupContaining !== void 0) { template.beforeGroupContaining = this.beforeGroupContaining; } if (this.afterGroupContaining !== void 0) { template.afterGroupContaining = this.afterGroupContaining; } return template; } }; // src/renderer/NormalMenuItem.ts function normal(options) { return new NormalMenuItem(options); } var NormalMenuItem = class extends ContextMenuItem { constructor(options, type = "normal") { super(options, type); this.accelerator = options.accelerator; this.acceleratorWorksWhenHidden = options.acceleratorWorksWhenHidden; this.click = options.click; this.enabled = options.enabled; this.icon = options.icon; this.label = options.label; this.registerAccelerator = options.registerAccelerator; this.toolTip = options.toolTip; } toTemplate() { const template = super.toTemplate(); if (this.accelerator !== void 0) { template.accelerator = this.accelerator; } if (this.acceleratorWorksWhenHidden !== void 0) { template.acceleratorWorksWhenHidden = this.acceleratorWorksWhenHidden; } if (this.enabled !== void 0) { template.enabled = this.enabled; } if (this.icon !== void 0) { template.icon = this.icon; } if (this.label !== void 0) { template.label = this.label; } if (this.registerAccelerator !== void 0) { template.registerAccelerator = this.registerAccelerator; } if (this.toolTip !== void 0) { template.toolTip = this.toolTip; } return template; } }; // src/renderer/CheckboxMenuItem.ts function checkbox(options) { return new CheckboxMenuItem(options); } var CheckboxMenuItem = class extends NormalMenuItem { constructor(options) { super(options, "checkbox"); this.checked = options.checked; } toTemplate() { const template = super.toTemplate(); if (this.checked !== void 0) { template.checked = this.checked; } return template; } }; // src/renderer/RadioMenuItem.ts function radio(options) { return new RadioMenuItem(options); } var RadioMenuItem = class _RadioMenuItem extends NormalMenuItem { constructor(options) { super(options, "radio"); this.checked = options.checked; } select() { this.checked = true; if (this.parent !== void 0) { for (const item of this.parent) { if (item instanceof _RadioMenuItem && item !== this) { item.checked = false; } } } } toTemplate() { const template = super.toTemplate(); if (this.checked !== void 0) { template.checked = this.checked; } return template; } }; // src/renderer/RoleMenuItem.ts function role(init) { return new RoleMenuItem(init); } var RoleMenuItem = class extends ContextMenuItem { constructor(init) { super(typeof init === "string" ? { role: init } : init, void 0); if (typeof init === "string") { this.role = init; } else { const options = init ?? {}; if (options.role === void 0) { throw new Error("Role is required for a role menu item"); } this.accelerator = options.accelerator; this.acceleratorWorksWhenHidden = options.acceleratorWorksWhenHidden; this.enabled = options.enabled; this.icon = options.icon; this.registerAccelerator = options.registerAccelerator; this.role = options.role; this.tooltip = options.tooltip; } } toTemplate() { const template = super.toTemplate(); template.role = this.role; if (this.accelerator !== void 0) { template.accelerator = this.accelerator; } if (this.acceleratorWorksWhenHidden !== void 0) { template.acceleratorWorksWhenHidden = this.acceleratorWorksWhenHidden; } if (this.enabled !== void 0) { template.enabled = this.enabled; } if (this.icon !== void 0) { template.icon = this.icon; } if (this.registerAccelerator !== void 0) { template.registerAccelerator = this.registerAccelerator; } if (this.tooltip !== void 0) { template.toolTip = this.tooltip; } return template; } }; // src/renderer/SeparatorMenuItem.ts function separator(options) { return new SeparatorMenuItem(options); } var SeparatorMenuItem = class extends ContextMenuItem { constructor(options) { super(options, "separator"); } toTemplate() { return super.toTemplate(); } }; // src/renderer/ShareMenuItem.ts function shareMenu(options) { return new ShareMenuItem(options); } var ShareMenuItem = class extends ContextMenuItem { constructor(options) { super(options, void 0); this.filePaths = options.filePaths; this.texts = options.texts; this.urls = options.urls; } toTemplate() { this.validate(); const template = super.toTemplate(); const sharingItem = {}; if (this.filePaths !== void 0) { sharingItem.filePaths = this.filePaths; } if (this.texts !== void 0) { sharingItem.texts = this.texts; } if (this.urls !== void 0) { sharingItem.urls = this.urls; } template.role = "shareMenu"; template.sharingItem = sharingItem; return template; } /** * Ensures the share menu has at least 1 sharing item. * * @throws Error If nothing is being shared. */ validate() { for (const entry of [this.filePaths, this.texts, this.urls]) { if ((entry ?? []).length !== 0) { return; } } throw new Error("A share menu must have at least 1 file path, text, or URL"); } }; // src/renderer/MenuBuilder.ts var MenuBuilder = class { #items = []; /** Items in the context menu. */ get items() { return this.#items; } /** * Adds the specified `item` to the context menu. * * @param item Context menu item to add. */ add(item) { this.#items.push(item); return this; } /** * Iterates over the specified `values` and calls the `onValue` callback to * add menu items to the menu. * * This is useful for adding multiple submenu items in a builder function. * * @param values Array of values of any type to iterate over. * @param onValue Callback called for each value. * * @example * const menu = contextMenu((builder) => * builder.submenu( * { * id: "submenu", * label: "Radio Options", * sublabel: "This is a sublabel", * }, * (builder) => * builder.map(["1", "2", "3"], (value) => * builder.radio({ * label: `Option ${value}`, * checked: activeOption === value, * click() { * console.log(`Clicked ${value}`); * }, * }), * ), * ), * ); */ map(values, onValue) { for (const value of values) { onValue(value); } return this; } /** Adds a checkbox menu item to the menu with the specified options. */ checkbox(options) { this.#items.push(new CheckboxMenuItem(options)); return this; } /** Adds a normal menu item to the menu with the specified options. */ normal(options) { this.#items.push(new NormalMenuItem(options)); return this; } /** Adds a radio menu item to the menu with the specified options. */ radio(options) { this.#items.push(new RadioMenuItem(options)); return this; } /** Adds a role menu item to the menu with the specified options. */ role(options) { this.#items.push(new RoleMenuItem(options)); return this; } /** Adds a separator menu item to the menu. */ separator(options) { this.#items.push(new SeparatorMenuItem(options)); return this; } /** * Adds a Share Menu item to the menu. * * @platforms macOS */ shareMenu(options) { this.#items.push(new ShareMenuItem(options)); return this; } submenu(options, init) { this.#items.push(new SubmenuMenuItem(options, init)); return this; } }; // src/renderer/SubmenuMenuItem.ts function submenu(options, init) { return new SubmenuMenuItem(options, init); } var SubmenuMenuItem = class extends ContextMenuItem { constructor(options, init) { super(options, "submenu"); this.enabled = options.enabled; this.icon = options.icon; this.items = Array.isArray(init) ? init : init(new MenuBuilder()).items; this.label = options.label; this.toolTip = options.toolTip; } *[Symbol.iterator]() { for (const item of this.items) { yield item; } } toTemplate() { const template = super.toTemplate(); if (this.enabled !== void 0) { template.enabled = this.enabled; } if (this.icon !== void 0) { template.icon = this.icon; } if (this.label !== void 0) { template.label = this.label; } if (this.toolTip !== void 0) { template.toolTip = this.toolTip; } const submenu2 = []; for (const item of this.items) { item.parent = this; submenu2.push(item.toTemplate()); } return { ...template, submenu: submenu2 }; } }; // src/sandbox/globals.ts var hoverboardApiKey = "__laserware_hoverboard__"; function getHoverboardGlobals() { const windowGlobals = window[hoverboardApiKey]; if (windowGlobals === void 0) { throw new Error("Globals not found, need to use preload"); } return { showContextMenu(request) { return windowGlobals.showContextMenu(request); }, hideContextMenu(menuId) { return windowGlobals.hideContextMenu(menuId); } }; } // src/renderer/ContextMenuEvent.ts var ContextMenuEvent = class extends Event { constructor(type, eventInitDict) { const { clientX, clientY, menuItem, triggeredByAccelerator, ...rest } = eventInitDict; super(type, rest); this.clientX = clientX ?? 0; this.clientY = clientY ?? 0; this.menuItem = menuItem; this.triggeredByAccelerator = triggeredByAccelerator ?? false; } }; // src/renderer/ContextMenu.ts function contextMenu(init) { return new ContextMenu(init); } var ContextMenu = class extends EventTarget { constructor(init) { super(); this.id = window.crypto.randomUUID().substring(0, 6); this.items = Array.isArray(init) ? init : init(new MenuBuilder()).items; } *[Symbol.iterator]() { for (const item of this.items) { yield item; } } /** * Appends the specified `item` to the context menu. * * @param item Menu item to append to context menu. */ append(item) { this.items.push(item); } /** * Inserts the specified `item` in the specified position of the context * menu. * * @param position Position in the menu to add the item. * @param item Item to insert into the context menu. */ insert(position, item) { this.items = [ ...this.items.slice(0, position), item, ...this.items.slice(position) ]; } /** * Removes the specified `item` from the context menu. * * @param item Menu item to remove from context menu. */ remove(item) { this.items = this.items.filter((existingItem) => existingItem !== item); } /** * Returns the menu item associated with the specified `id`. If the menu * item doesn't exist, returns null. * * @param id ID of the menu item to find. * * @returns The menu item if found, otherwise null. */ getMenuItemById(id) { for (const item of walkContextMenu(this)) { if (item.id === id) { return item; } } return null; } /** * Converts the contents of the context menu item to a serializable template * that is sent to the main process to build the context menu. */ toTemplate() { const template = []; for (const item of this.items) { template.push(item.toTemplate()); } return template; } addEventListener(type, callback, options) { super.addEventListener(type, callback, options); } removeEventListener(type, callback, options) { super.removeEventListener(type, callback, options); } /** * Hides the context menu (if it is open). */ async hide() { const globals = getHoverboardGlobals(); await globals.hideContextMenu(this.id); this.dispatchEvent(new ContextMenuEvent("hide", { menuItem: null })); } /** * Shows the context menu in the specified `x` and `y` coordinates. * * @param x X location to display the context menu. * @param y Y location to display the context menu. * * @returns Promise resolving with the menu item that was clicked. If no menu * item was clicked, resolves to `null`. */ async show(x, y) { const template = this.toTemplate(); const globals = getHoverboardGlobals(); const dispatchHideEvent = (menuItem2, triggeredByAccelerator) => { this.dispatchEvent( new ContextMenuEvent("hide", { clientX: x, clientY: y, menuItem: menuItem2, triggeredByAccelerator }) ); }; let linkURL; for (const element of document.elementsFromPoint(x, y)) { if (element instanceof HTMLAnchorElement) { linkURL = element.href || void 0; } if (linkURL !== void 0) { break; } } const promise = globals.showContextMenu({ menuId: this.id, position: { x, y }, template, linkURL }); this.dispatchEvent( new ContextMenuEvent("show", { clientX: x, clientY: y, menuItem: null }) ); const response = await promise; if (response.menuId !== this.id) { return null; } if (response.menuItemId === null) { dispatchHideEvent(null); return null; } let menuItem = null; for (const item of walkContextMenu(this)) { if (item.id === response.menuItemId) { menuItem = item; break; } } if (menuItem === null) { dispatchHideEvent(null); return null; } const event = new ContextMenuEvent("click", { ...response.event, clientX: x, clientY: y, menuItem }); this.dispatchEvent(event); if (isClickable(menuItem)) { menuItem.click(menuItem, event); } dispatchHideEvent(menuItem, response.event.triggeredByAccelerator); return menuItem; } }; function isClickable(value) { return "click" in value && value.click !== void 0; } function* walkContextMenu(menu) { function* recurse(items2) { for (const item of items2) { yield item; if (item instanceof SubmenuMenuItem) { yield* recurse(item.items); } } } yield* recurse(menu.items); } // src/renderer/index.ts var items = { checkbox, normal, radio, role, separator, shareMenu, submenu }; export { CheckboxMenuItem, ContextMenu, ContextMenuEvent, NormalMenuItem, RadioMenuItem, RoleMenuItem, SeparatorMenuItem, ShareMenuItem, SubmenuMenuItem, checkbox, contextMenu, items, normal, radio, role, separator, shareMenu, submenu }; //# sourceMappingURL=renderer.mjs.map //# sourceMappingURL=renderer.mjs.map