448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
"use strict";
|
|
// License: MIT
|
|
|
|
import {EventEmitter} from "./events";
|
|
import {Rect} from "./rect";
|
|
import {debounce, IS_MAC} from "./util";
|
|
|
|
const CLICK_DIFF = 16;
|
|
const MENU_OPEN_BOUNCE = 500;
|
|
|
|
let ids = 0;
|
|
|
|
const Keys = new Map([
|
|
["ACCEL", IS_MAC ? "⌘" : "Ctrl"],
|
|
["CTRL", "Ctrl"],
|
|
["ALT", IS_MAC ? "⌥" : "Alt"],
|
|
["SHIFT", "⇧"],
|
|
]);
|
|
|
|
function toKeyTextMap(k: string) {
|
|
k = k.trim();
|
|
const ku = k.toUpperCase();
|
|
const v = Keys.get(ku);
|
|
return v ? v : k.startsWith("Key") ? k.slice(3) : k;
|
|
}
|
|
|
|
function toKeyText(key: string) {
|
|
return key.split("-").map(toKeyTextMap).join(" ");
|
|
}
|
|
|
|
export interface MenuPosition {
|
|
clientX: number,
|
|
clientY: number,
|
|
}
|
|
|
|
export class MenuItemBase {
|
|
public readonly owner: ContextMenu;
|
|
|
|
public readonly id: string;
|
|
|
|
public readonly text: string;
|
|
|
|
public readonly icon: string;
|
|
|
|
public readonly key: string;
|
|
|
|
public readonly autohide: boolean;
|
|
|
|
public readonly elem: HTMLLIElement;
|
|
|
|
public readonly iconElem: HTMLSpanElement;
|
|
|
|
public readonly textElem: HTMLSpanElement;
|
|
|
|
public readonly keyElem: HTMLSpanElement;
|
|
|
|
constructor(owner: ContextMenu, id = "", text = "", {
|
|
key = "", icon = "", autohide = Object()
|
|
}) {
|
|
this.owner = owner;
|
|
if (!id) {
|
|
id = `contextmenu-${++ids}`;
|
|
}
|
|
this.id = id;
|
|
this.text = text || "";
|
|
this.icon = icon || "";
|
|
this.key = key || "";
|
|
this.autohide = autohide !== "false" && autohide !== false;
|
|
|
|
this.elem = document.createElement("li");
|
|
this.elem.id = this.id;
|
|
this.iconElem = document.createElement("span");
|
|
this.textElem = document.createElement("span");
|
|
this.keyElem = document.createElement("span");
|
|
this.elem.appendChild(this.iconElem);
|
|
this.elem.appendChild(this.textElem);
|
|
this.elem.appendChild(this.keyElem);
|
|
}
|
|
|
|
materialize() {
|
|
this.elem.classList.add("context-menu-item");
|
|
this.iconElem.className = "context-menu-icon";
|
|
if (this.icon) {
|
|
this.iconElem.classList.add(...this.icon.split(" "));
|
|
}
|
|
this.textElem.textContent = this.text;
|
|
this.textElem.className = "context-menu-text";
|
|
|
|
if (this.key) {
|
|
this.elem.dataset.key = this.key;
|
|
}
|
|
this.keyElem.textContent = toKeyText(this.key);
|
|
this.keyElem.className = "context-menu-key";
|
|
this.keyElem.style.display = this.key ? "inline-block" : "none";
|
|
}
|
|
}
|
|
|
|
export class MenuItem extends MenuItemBase {
|
|
constructor(owner: ContextMenu, id = "", text = "", options: any = {}) {
|
|
options = options || {};
|
|
super(owner, id, text, options);
|
|
this.disabled = !!options.disabled;
|
|
this.elem.setAttribute("aria-role", "menuitem");
|
|
this.elem.addEventListener(
|
|
"click", () => this.owner.emit("clicked", this.id, this.autohide));
|
|
}
|
|
|
|
get disabled() {
|
|
return this.elem.classList.contains("disabled");
|
|
}
|
|
|
|
set disabled(nv) {
|
|
this.elem.classList[nv ? "add" : "remove"]("disabled");
|
|
}
|
|
}
|
|
|
|
export class MenuSeperatorItem extends MenuItemBase {
|
|
constructor(owner: ContextMenu, id = "") {
|
|
super(owner, id, "", {});
|
|
this.elem.setAttribute("aria-role", "menuitem");
|
|
this.elem.setAttribute("aria-hidden", "true");
|
|
}
|
|
|
|
materialize() {
|
|
super.materialize();
|
|
this.elem.classList.add("context-menu-seperator");
|
|
}
|
|
}
|
|
|
|
export class SubMenuItem extends MenuItemBase {
|
|
public readonly menu: ContextMenu;
|
|
|
|
public readonly expandElem: HTMLSpanElement;
|
|
|
|
constructor(owner: ContextMenu, id = "", text = "", options: any = {}) {
|
|
super(owner, id, text, options);
|
|
this.elem.setAttribute("aria-role", "menuitem");
|
|
this.elem.setAttribute("aria-haspopup", "true");
|
|
|
|
this.menu = new ContextMenu();
|
|
|
|
this.expandElem = document.createElement("span");
|
|
this.expandElem.className = "context-menu-expand";
|
|
this.expandElem.textContent = "►";
|
|
this.elem.appendChild(this.expandElem);
|
|
this.elem.addEventListener("click", event => {
|
|
if (options.allowClick) {
|
|
this.owner.emit("clicked", this.id, this.autohide);
|
|
}
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
return false;
|
|
}, true);
|
|
this.owner.elem.addEventListener(
|
|
"mouseenter", debounce(this.entered.bind(this), MENU_OPEN_BOUNCE), true);
|
|
this.owner.on("dismissed", () => {
|
|
this.menu.dismiss();
|
|
});
|
|
this.owner.on("showing", () => {
|
|
this.menu.dismiss();
|
|
});
|
|
this.menu.on("clicked", (...args: any) => {
|
|
this.owner.emit("clicked", ...args);
|
|
});
|
|
}
|
|
|
|
get itemRect() {
|
|
return new Rect(
|
|
this.owner.elem.offsetLeft,
|
|
this.owner.elem.offsetTop + this.elem.offsetTop,
|
|
0,
|
|
0,
|
|
this.elem.clientWidth - 2,
|
|
this.elem.clientHeight,
|
|
);
|
|
}
|
|
|
|
entered(event: MouseEvent) {
|
|
const {target} = event;
|
|
const htarget = <HTMLElement> target;
|
|
if (htarget.classList.contains("context-menu")) {
|
|
return;
|
|
}
|
|
if (htarget !== this.elem && htarget.parentElement !== this.elem) {
|
|
this.menu.dismiss();
|
|
return;
|
|
}
|
|
if (!this.owner.showing) {
|
|
return;
|
|
}
|
|
const {itemRect} = this;
|
|
const {availableRect} = this.owner;
|
|
const {width, height} = this.menu.elem.getBoundingClientRect();
|
|
if (itemRect.right + width > availableRect.right) {
|
|
itemRect.offset(-(itemRect.width + width - 2), 0);
|
|
}
|
|
if (itemRect.bottom + height > availableRect.bottom) {
|
|
itemRect.offset(0, itemRect.height);
|
|
}
|
|
this.menu.show({clientX: itemRect.right, clientY: itemRect.top});
|
|
}
|
|
|
|
constructFromTemplate(el: HTMLElement) {
|
|
this.menu.constructFromTemplate(el);
|
|
}
|
|
|
|
materialize() {
|
|
super.materialize();
|
|
this.menu.materialize();
|
|
this.elem.classList.add("context-menu-submenuitem");
|
|
}
|
|
}
|
|
|
|
export class ContextMenu extends EventEmitter {
|
|
id: string;
|
|
|
|
items: any[];
|
|
|
|
itemMap: Map<string, MenuItemBase>;
|
|
|
|
elem: HTMLUListElement;
|
|
|
|
showing: boolean;
|
|
|
|
_maybeDismiss: any;
|
|
|
|
constructor(el?: any) {
|
|
super();
|
|
this.id = `contextmenu-${++ids}`;
|
|
this.items = [];
|
|
this.itemMap = new Map();
|
|
this.elem = document.createElement("ul");
|
|
this.elem.classList.add("context-menu");
|
|
if (el) {
|
|
this.constructFromTemplate(el);
|
|
}
|
|
this.dismiss = this.dismiss.bind(this);
|
|
this.hide();
|
|
this.materialize();
|
|
}
|
|
|
|
get availableRect() {
|
|
const {clientWidth: bodyWidth, clientHeight: bodyHeight} = document.body;
|
|
const availableRect = new Rect(0, 0, 0, 0, bodyWidth, bodyHeight);
|
|
return availableRect;
|
|
}
|
|
|
|
show(event: MenuPosition) {
|
|
this.dismiss();
|
|
this.emit("showing");
|
|
this.materialize();
|
|
const {clientX, clientY} = event;
|
|
const {clientWidth, clientHeight} = this.elem;
|
|
const clientRect = new Rect(
|
|
clientX, clientY, 0, 0, clientWidth, clientHeight);
|
|
const {availableRect} = this;
|
|
if (clientRect.left < 0) {
|
|
clientRect.move(0, clientRect.top);
|
|
}
|
|
if (clientRect.left < 0) {
|
|
clientRect.move(clientRect.left, 0);
|
|
}
|
|
if (clientRect.bottom > availableRect.bottom) {
|
|
clientRect.offset(0, -(clientRect.height));
|
|
}
|
|
if (clientRect.right > availableRect.right) {
|
|
clientRect.offset(-(clientRect.width), 0);
|
|
}
|
|
if (clientRect.top < 0) {
|
|
clientRect.offset(0, -(clientRect.top));
|
|
}
|
|
this.elem.style.left = `${clientRect.left}px`;
|
|
this.elem.style.top = `${clientRect.top}px`;
|
|
this.showing = true;
|
|
this._maybeDismiss = this.maybeDismiss.bind(this, event);
|
|
addEventListener("click", this._maybeDismiss, true);
|
|
addEventListener("keydown", this.dismiss, true);
|
|
return true;
|
|
}
|
|
|
|
dismiss() {
|
|
if (!this.showing) {
|
|
return;
|
|
}
|
|
removeEventListener("click", this._maybeDismiss, true);
|
|
removeEventListener("keydown", this.dismiss, true);
|
|
this.showing = false;
|
|
this.hide();
|
|
this.emit("dismissed");
|
|
}
|
|
|
|
destroy() {
|
|
if (this.elem.parentElement) {
|
|
this.elem.parentElement.removeChild(this.elem);
|
|
}
|
|
delete this.elem;
|
|
this.items.length = 0;
|
|
}
|
|
|
|
maybeDismiss(origEvent: MouseEvent, event: MouseEvent) {
|
|
if (!event) {
|
|
return;
|
|
}
|
|
if (event.type === "click" && event.button === 2 &&
|
|
origEvent.target === event.target &&
|
|
Math.abs(event.clientX - origEvent.clientX) < CLICK_DIFF &&
|
|
Math.abs(event.clientY - origEvent.clientY) < CLICK_DIFF) {
|
|
return;
|
|
}
|
|
let el = <HTMLElement> event.target;
|
|
while (el) {
|
|
if (el.classList.contains("context-menu")) {
|
|
return;
|
|
}
|
|
if (!el.parentElement) {
|
|
break;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
this.dismiss();
|
|
}
|
|
|
|
emit(event: string, ...args: any[]) {
|
|
if (event !== "showing") {
|
|
// non-autohide click?
|
|
if (event !== "clicked" || args.length < 2 || args[1]) {
|
|
this.dismiss();
|
|
}
|
|
}
|
|
const rv = super.emit(event, ...args);
|
|
if (event === "clicked") {
|
|
return super.emit(args[0], ...args.slice(1));
|
|
}
|
|
return rv;
|
|
}
|
|
|
|
hide() {
|
|
this.elem.style.top = "0px";
|
|
this.elem.style.left = "-10000px";
|
|
}
|
|
|
|
*[Symbol.iterator]() {
|
|
yield *this.itemMap.keys();
|
|
}
|
|
|
|
get(id: string) {
|
|
return this.itemMap.get(id);
|
|
}
|
|
|
|
add(item: MenuItemBase, before: any = "") {
|
|
let idx = this.items.length;
|
|
if (before) {
|
|
before = before.id || before;
|
|
const ni = this.items.findIndex(i => i.id === before);
|
|
if (ni >= 0) {
|
|
idx = ni;
|
|
}
|
|
}
|
|
this.items.splice(idx, 0, item);
|
|
this.itemMap.set(item.id, item);
|
|
}
|
|
|
|
prepend(item: MenuItemBase) {
|
|
this.items.unshift(item);
|
|
this.itemMap.set(item.id, item);
|
|
}
|
|
|
|
remove(item: any) {
|
|
const id = item.id || item;
|
|
const idx = this.items.findIndex(i => i.id === id);
|
|
if (idx >= 0) {
|
|
this.items.splice(idx, 1);
|
|
this.itemMap.delete(id);
|
|
}
|
|
}
|
|
|
|
constructFromTemplate(el: HTMLElement | string) {
|
|
if (typeof el === "string") {
|
|
const sel = document.querySelector(el) as HTMLElement;
|
|
if (!sel) {
|
|
throw new Error("Invalid selector");
|
|
}
|
|
el = sel;
|
|
}
|
|
if (el.parentElement) {
|
|
el.parentElement.removeChild(el);
|
|
}
|
|
if (el.localName === "template") {
|
|
el = <HTMLElement> (<HTMLTemplateElement> el).content.firstElementChild;
|
|
}
|
|
if (el.className) {
|
|
this.elem.className = el.className;
|
|
this.elem.classList.add("context-menu");
|
|
}
|
|
this.id = el.id || this.id;
|
|
for (const child of el.children) {
|
|
const text = [];
|
|
let sub = null;
|
|
for (const sc of child.childNodes) {
|
|
switch (sc.nodeType) {
|
|
case Node.TEXT_NODE: {
|
|
const {textContent} = sc;
|
|
text.push(textContent && textContent.trim() || "");
|
|
break;
|
|
}
|
|
|
|
case Node.ELEMENT_NODE:
|
|
if (sub) {
|
|
throw new Error("Already has a submenu");
|
|
}
|
|
if ((<HTMLElement> sc).localName !== "ul") {
|
|
throw new Error("Not a valid submenu");
|
|
}
|
|
sub = sc;
|
|
break;
|
|
default:
|
|
throw new Error(`Invalid node: ${(<HTMLElement> sc).localName}`);
|
|
}
|
|
}
|
|
const joined = text.join(" ").trim();
|
|
let item = null;
|
|
const ce = <HTMLElement> child;
|
|
if (joined === "-") {
|
|
item = new MenuSeperatorItem(this, child.id);
|
|
}
|
|
else if (sub) {
|
|
item = new SubMenuItem(this, child.id, joined, ce.dataset);
|
|
item.constructFromTemplate(<HTMLElement> sub);
|
|
}
|
|
else {
|
|
item = new MenuItem(this, child.id, joined, ce.dataset);
|
|
}
|
|
this.items.push(item);
|
|
this.itemMap.set(item.id, item);
|
|
}
|
|
}
|
|
|
|
materialize() {
|
|
this.elem.id = this.id;
|
|
this.elem.textContent = "";
|
|
for (const item of this.items) {
|
|
item.materialize();
|
|
this.elem.appendChild(item.elem);
|
|
}
|
|
document.body.appendChild(this.elem);
|
|
}
|
|
}
|