@laserware/hoverboard
Version:
Better context menus for Electron.
349 lines (281 loc) • 8.83 kB
text/typescript
import type { MenuItemConstructorOptions } from "electron";
import { getHoverboardGlobals } from "../sandbox/globals.js";
import {
ContextMenuEvent,
type ContextMenuEventInit,
type ContextMenuEventListenerOrEventListenerObject,
} from "./ContextMenuEvent.js";
import { ContextMenuItemElement, property } from "./ContextMenuItemElement.js";
import { SharingItemEntryElement } from "./SharingItemEntryElement.js";
import { SubmenuMenuItemElement } from "./SubmenuMenuItemElement.js";
export interface ContextMenuAttributes {
id: string | null;
target?: string | null;
}
export class ContextMenuElement extends HTMLElement {
#controllers: Map<HTMLElement | string, AbortController> = new Map();
#trigger: HTMLElement | null = null;
constructor() {
super();
this.style.display = "none";
this.attachShadow({ mode: "closed" });
}
*[Symbol.iterator]() {
for (let index = 0; index < this.children.length; index++) {
yield this.children.item(index);
}
}
@property({ type: String })
public id!: string;
@property({ type: String })
public target: string | undefined;
public connectedCallback(): void {
this.setAttribute("inert", "");
if (this.getAttribute("id") === null) {
this.id = window.crypto.randomUUID().substring(0, 6);
}
if (this.target !== undefined) {
this.attachTo(this.target);
}
}
public disconnectedCallback(): void {
this.dispose();
}
public getAttribute(name: keyof ContextMenuAttributes) {
return super.getAttribute(name);
}
public toTemplate(): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = [];
const walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT);
let node = walker.firstChild();
while (node !== null) {
// These don't get added to the menu template, they're used within a
// ShareMenu menu item to define sharing items:
if (node instanceof SharingItemEntryElement) {
node = walker.nextNode();
continue;
}
if (node instanceof ContextMenuItemElement) {
if (node.parentNode instanceof SubmenuMenuItemElement) {
node = walker.nextNode();
continue;
} else {
template.push(node.toTemplate());
}
}
node = walker.nextNode();
}
return template;
}
public addEventListener(
type: "attach",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public addEventListener(
type: "click",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public addEventListener(
type: "hide",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public addEventListener(
type: "show",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) {
super.addEventListener(
type,
listener as EventListenerOrEventListenerObject,
options,
);
}
public removeEventListener(
type: "attach",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public removeEventListener(
type: "click",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public removeEventListener(
type: "hide",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public removeEventListener(
type: "show",
listener: ContextMenuEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
public removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) {
super.removeEventListener(
type,
listener as EventListenerOrEventListenerObject,
options,
);
}
public async hide(): Promise<void> {
const globals = getHoverboardGlobals();
await globals.hideContextMenu(this.id);
this.dispatchEvent(
new ContextMenuEvent("hide", {
menu: this,
menuItem: null,
trigger: this.#trigger,
}),
);
}
public async show(
x: number,
y: number,
): Promise<ContextMenuItemElement | null> {
const template = this.toTemplate();
const globals = getHoverboardGlobals();
const dispatchHideEvent = (
menuItem: ContextMenuItemElement | null,
triggeredByAccelerator?: boolean,
): void => {
this.dispatchEvent(
new ContextMenuEvent("hide", {
clientX: x,
clientY: y,
menu: this,
menuItem,
trigger: this.#trigger,
triggeredByAccelerator,
}),
);
};
let linkURL: string | undefined;
// If any of the elements in the clicked point are an anchor with an
// `href` property, send that to the context menu builder in the main
// process so we can add the appropriate link actions to the menu:
for (const element of document.elementsFromPoint(x, y)) {
if (element instanceof HTMLAnchorElement && linkURL === undefined) {
linkURL = element.href || undefined;
}
}
const response = await globals.showContextMenu({
menuId: this.id,
position: { x, y },
template,
linkURL,
});
this.dispatchEvent(
new ContextMenuEvent("show", {
clientX: x,
clientY: y,
menu: this,
menuItem: null,
trigger: this.#trigger,
}),
);
if (response.menuId !== this.id) {
return null;
}
const menuItem =
response.menuItemId === null
? null
: this.#findMenuItem(response.menuItemId);
if (!(menuItem instanceof ContextMenuItemElement)) {
dispatchHideEvent(null);
return null;
}
const clickInit = {
...response.event,
clientX: x,
clientY: y,
menu: this,
menuItem,
trigger: this.#trigger,
} satisfies ContextMenuEventInit;
menuItem.dispatchEvent(new ContextMenuEvent("click", clickInit));
this.dispatchEvent(new ContextMenuEvent("click", clickInit));
dispatchHideEvent(menuItem, response.event.triggeredByAccelerator);
return menuItem;
}
#findMenuItem(id: string): HTMLElement | null {
const walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT);
let node = walker.firstChild();
while (node !== null) {
if (node instanceof HTMLElement && "id" in node && node.id === id) {
return node;
}
node = walker.nextNode();
}
return null;
}
public attachTo(target: HTMLElement | string): void {
const controller = new AbortController();
const { signal } = controller;
if (target instanceof HTMLElement) {
this.#trigger = target;
}
const handleContextMenu = async (event: MouseEvent): Promise<void> => {
let trigger: HTMLElement | null = null;
if (target instanceof HTMLElement) {
trigger = target;
} else {
const element = event.target as HTMLElement;
if (element.matches(target)) {
trigger = element;
} else {
const { clientX: x, clientY: y } = event;
for (const element of document.elementsFromPoint(x, y)) {
if (element.matches(target)) {
trigger = element as HTMLElement;
break;
}
}
}
}
this.#trigger = trigger;
if (trigger === null) {
return;
}
event.preventDefault();
this.dispatchEvent(
new ContextMenuEvent("attach", {
...event,
menu: this,
menuItem: null,
trigger,
triggeredByAccelerator: false,
}),
);
await this.show(event.clientX, event.clientY);
};
const options = { signal, capture: true };
if (target instanceof HTMLElement) {
target.addEventListener("contextmenu", handleContextMenu, options);
} else {
window.addEventListener("contextmenu", handleContextMenu, options);
}
this.#controllers.set(target, controller);
}
public detach(): void {
for (const controller of this.#controllers.values()) {
controller.abort();
}
this.#controllers.clear();
this.#trigger = null;
}
public dispose(): void {
this.detach();
}
}