/* eslint-disable react/no-array-index-key */ /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable default-param-last */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, IReactionDisposer, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DivHeight, DivWidth } from '../../ClientUtils'; import { SnappingManager } from '../util/SnappingManager'; import './ContextMenu.scss'; import { ContextMenuItem, ContextMenuProps } from './ContextMenuItem'; import { ObservableReactComponent } from './ObservableReactComponent'; @observer export class ContextMenu extends ObservableReactComponent<{ noexpand?: boolean }> { // eslint-disable-next-line no-use-before-define static Instance: ContextMenu; private _ignoreUp = false; private _reactionDisposer?: IReactionDisposer; private _defaultPrefix: string = ''; private _defaultItem: ((name: string) => void) | undefined; private _onDisplay?: () => void = undefined; @observable.shallow _items: ContextMenuProps[] = []; @observable _pageX: number = 0; @observable _pageY: number = 0; @observable _display: boolean = false; @observable _searchString: string = ''; @observable _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 _yRelativeToTop: boolean = true; @observable _selectedIndex = -1; @observable _width: number = 0; @observable _height: number = 0; @observable _mouseX: number = -1; @observable _mouseY: number = -1; @observable _shouldDisplay: boolean = false; constructor(props: object) { super(props); makeObservable(this); 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) => { if (e.button !== 2 && !e.ctrlKey) return; 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, true); document.removeEventListener('pointerup', this.onPointerUp); this._reactionDisposer?.(); } componentDidMount() { document.addEventListener('pointerdown', this.onPointerDown, true); document.addEventListener('pointerup', this.onPointerUp); } @action clearItems() { this._items.length = 0; this._defaultPrefix = ''; this._defaultItem = undefined; } findByDescription = (target: string, toLowerCase = false) => this._items.find(menuItem => (toLowerCase ? menuItem.description.toLowerCase() : menuItem.description) === target); // prettier-ignore @action addItem(item: ContextMenuProps) { !this._items.includes(item) && this._items.push(item); } @action moveAfter(item: ContextMenuProps, after?: ContextMenuProps) { const curInd = this._items.findIndex(i => i.description === item.description); this._items.splice(curInd, 1); const afterInd = after && this.findByDescription(after.description) ? this._items.findIndex(i => i.description === after.description) : this._items.length; this._items.splice(afterInd, 0, item); } @action setDefaultItem(prefix: string, item: (name: string) => void) { this._defaultPrefix = prefix; this._defaultItem = item; } static readonly buffer = 20; get pageX() { return this._pageX + this._width > window.innerWidth - ContextMenu.buffer ? window.innerWidth - ContextMenu.buffer - this._width : Math.max(0, this._pageX); } get pageY() { return this._pageY + this._height > window.innerHeight - ContextMenu.buffer ? window.innerHeight - ContextMenu.buffer - this._height : Math.max(0, this._pageY); } @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(): (ContextMenuProps | string[])[] { const searchString = this._searchString.toLowerCase().split(' '); const matches = (descriptions: string[]) => searchString.every(s => descriptions.some(desc => desc.toLowerCase().includes(s))); const flattenItems = (items: ContextMenuProps[], groupFunc: (groupName: string) => string[]) => { let eles: (ContextMenuProps | string[])[] = []; const leaves: ContextMenuProps[] = []; items.forEach(item => { const { description } = item; const path = groupFunc(description); if (item.subitems) { 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)) { leaves.push(item as ContextMenuProps); } }); eles = [...leaves, ...eles]; return eles; }; return flattenItems(this._items.slice(), name => [name]); } @computed get flatItems(): ContextMenuProps[] { return this.filteredItems.filter(item => !Array.isArray(item)) as ContextMenuProps[]; } @computed get menuItems() { if (!this._searchString) { return this._items.map((item, ind) => ); } return this.filteredItems.map((value, index) => Array.isArray(value) ? (
')} className="contextMenu-group" style={{ background: SnappingManager.userVariantColor, }}>
{value.join(' -> ')}
) : ( ) ); } @computed get itemsNeedSearch() { return this._showSearch ? 1 : this._items.reduce((p, mi) => p + (mi.noexpand ? 1 : mi.subitems?.length || 1), 0) > 15; } _searchRef = React.createRef(); // bcz: we shouldn't need this, since we set autoFocus on the tag, but for some reason we do... render() { this.itemsNeedSearch && setTimeout(() => this._searchRef.current?.focus()); return (
runInAction(() => { if (r) { this._width = DivWidth(r); this._height = DivHeight(r); } this._searchRef.current?.focus(); }) } style={{ display: this._display ? '' : 'none', left: this.pageX, ...(this._yRelativeToTop ? { top: Math.max(0, this.pageY) } : { bottom: this.pageY }), background: SnappingManager.userBackgroundColor, color: SnappingManager.userColor, }}> {!this.itemsNeedSearch ? null : ( )} {this.menuItems}
); } @action setLangIndex = (ind: number) => { this._selectedIndex = ind; }; @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.event) { 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); } }; }