@laserware/hoverboard
Version:
Better context menus for Electron.
592 lines (579 loc) • 16 kB
JavaScript
// 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