import React = require("react"); import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, IReactionDisposer, observable } from "mobx"; import { observer } from "mobx-react"; import "./ContextMenu.scss"; import { ContextMenuItem, ContextMenuProps, OriginalMenuProps } from "./ContextMenuItem"; import { Utils } from "../../Utils"; @observer export class ContextMenu extends React.Component { static Instance: ContextMenu; @observable private _items: Array = []; @observable private _pageX: number = 0; @observable private _pageY: number = 0; @observable private _display: boolean = false; @observable private _searchString: string = ""; @observable private _showSearch: boolean = false; // afaik displaymenu can be called before all the items are added to the menu, so can't determine in displayMenu what the height of the menu will be @observable private _yRelativeToTop: boolean = true; @observable selectedIndex = -1; @observable private _width: number = 0; @observable private _height: number = 0; @observable private _mouseX: number = -1; @observable private _mouseY: number = -1; @observable private _shouldDisplay: boolean = false; private _ignoreUp = false; private _reactionDisposer?: IReactionDisposer; constructor(props: Readonly<{}>) { super(props); ContextMenu.Instance = this; } public setIgnoreEvents(ignore: boolean) { this._ignoreUp = ignore; } @action onPointerDown = (e: PointerEvent) => { this._mouseX = e.clientX; this._mouseY = e.clientY; } @action onPointerUp = (e: PointerEvent) => { const curX = e.clientX; const curY = e.clientY; if (this._ignoreUp) { this._ignoreUp = false; return; } if (Math.abs(this._mouseX - curX) > 1 || Math.abs(this._mouseY - curY) > 1) { this._shouldDisplay = false; } if (this._shouldDisplay) { if (this._onDisplay) { this._onDisplay(); } else { this._display = true; } } } componentWillUnmount() { document.removeEventListener("pointerdown", this.onPointerDown); document.removeEventListener("pointerup", this.onPointerUp); this._reactionDisposer?.(); } @action componentDidMount = () => { document.addEventListener("pointerdown", this.onPointerDown); document.addEventListener("pointerup", this.onPointerUp); } @action clearItems() { this._items = []; this._defaultPrefix = ""; this._defaultItem = undefined; } _defaultPrefix: string = ""; _defaultItem: ((name: string) => void) | undefined; findByDescription = (target: string, toLowerCase = false) => { return this._items.find(menuItem => { let reference = menuItem.description; toLowerCase && (reference = reference.toLowerCase()); return reference === target; }); } @action addItem(item: ContextMenuProps) { if (this._items.indexOf(item) === -1) { this._items.push(item); } } @action moveAfter(item: ContextMenuProps, after: ContextMenuProps) { if (after && this.findByDescription(after.description)) { const curInd = this._items.findIndex((i) => i.description === item.description); this._items.splice(curInd, 1); const afterInd = this._items.findIndex((i) => i.description === after.description); this._items.splice(afterInd + 1, 0, item); } } @action setDefaultItem(prefix: string, item: (name: string) => void) { this._defaultPrefix = prefix; this._defaultItem = item; } static readonly buffer = 20; get pageX() { const x = this._pageX; if (x < 0) { return 0; } const width = this._width; if (x + width > window.innerWidth - ContextMenu.buffer) { return window.innerWidth - ContextMenu.buffer - width; } return x; } get pageY() { const y = this._pageY; if (y < 0) { return 0; } const height = this._height; if (y + height > window.innerHeight - ContextMenu.buffer) { return window.innerHeight - ContextMenu.buffer - height; } return y; } _onDisplay?: () => void = undefined; @action displayMenu = (x: number, y: number, initSearch = "", showSearch = false, onDisplay?: () => void) => { //maxX and maxY will change if the UI/font size changes, but will work for any amount //of items added to the menu this._showSearch = showSearch; this._pageX = x; this._pageY = y; this._searchString = initSearch; this._shouldDisplay = true; this._onDisplay = onDisplay; this._display = !onDisplay; } @action closeMenu = () => { const wasOpen = this._display; this.clearItems(); this._display = false; this._shouldDisplay = false; return wasOpen; } @computed get filteredItems(): (OriginalMenuProps | string[])[] { const searchString = this._searchString.toLowerCase().split(" "); const matches = (descriptions: string[]): boolean => { return searchString.every(s => descriptions.some(desc => desc.toLowerCase().includes(s))); }; const flattenItems = (items: ContextMenuProps[], groupFunc: (groupName: any) => string[]) => { let eles: (OriginalMenuProps | string[])[] = []; const leaves: OriginalMenuProps[] = []; for (const item of items) { const description = item.description; const path = groupFunc(description); if ("subitems" in item) { const children = flattenItems(item.subitems, name => [...groupFunc(description), name]); if (children.length || matches(path)) { eles.push(path); eles = eles.concat(children); } } else { if (!matches(path)) { continue; } leaves.push(item); } } eles = [...leaves, ...eles]; return eles; }; return flattenItems(this._items.slice(), name => [name]); } @computed get flatItems(): OriginalMenuProps[] { return this.filteredItems.filter(item => !Array.isArray(item)) as OriginalMenuProps[]; } @computed get menuItems() { if (!this._searchString) { return this._items.map((item, ind) => ); } return this.filteredItems.map((value, index) => Array.isArray(value) ?
{value.join(" -> ")}
: ); } @computed get itemsNeedSearch() { return this._showSearch ? 1 : this._items.reduce((p, mi) => p + ((mi as any).noexpand ? 1 : (mi as any).subitems?.length || 1), 0) > 15; } render() { return !this._display ? (null) :
{!this.itemsNeedSearch ? (null) : } {this.menuItems}
; } @action onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { if (this.selectedIndex < this.flatItems.length - 1) { this.selectedIndex++; } e.preventDefault(); } else if (e.key === "ArrowUp") { if (this.selectedIndex > 0) { this.selectedIndex--; } e.preventDefault(); } else if (e.key === "Enter" || e.key === "Tab") { const item = this.flatItems[this.selectedIndex]; if (item) { item.event({ x: this.pageX, y: this.pageY }); } else if (this._searchString.startsWith(this._defaultPrefix)) { this._defaultItem?.(this._searchString.substring(this._defaultPrefix.length)); } this.closeMenu(); e.preventDefault(); e.stopPropagation(); } } @action onChange = (e: React.ChangeEvent) => { this._searchString = e.target.value; if (!this._searchString) { this.selectedIndex = -1; } else { if (this.selectedIndex === -1) { this.selectedIndex = 0; } else { this.selectedIndex = Math.min(this.flatItems.length - 1, this.selectedIndex); } } } }