164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
import useSize from '@react-hook/size';
|
|
import * as React from 'react';
|
|
import { IPoint } from '../../Interfaces/IPoint';
|
|
import { MenuItem } from './MenuItem';
|
|
|
|
interface IMenuProps {
|
|
getListener: () => HTMLElement | null
|
|
actions: Map<string, IMenuAction[]>
|
|
className?: string
|
|
}
|
|
|
|
export interface IMenuAction {
|
|
/** displayed */
|
|
text: string
|
|
|
|
/** title to show on hover */
|
|
title?: string
|
|
|
|
/** description of the shortcut */
|
|
shortcut?: string
|
|
|
|
/** function to be called on button click */
|
|
action: (target: HTMLElement) => void
|
|
|
|
}
|
|
|
|
function UseMouseEvents(
|
|
getListener: () => HTMLElement | null,
|
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
|
setContextMenuPosition: React.Dispatch<React.SetStateAction<IPoint>>,
|
|
setTarget: React.Dispatch<React.SetStateAction<HTMLElement | undefined>>
|
|
): void {
|
|
React.useEffect(() => {
|
|
function OnContextMenu(event: MouseEvent): void {
|
|
event.preventDefault();
|
|
const contextMenuPosition: IPoint = { x: event.pageX, y: event.pageY };
|
|
|
|
setIsOpen(true);
|
|
setContextMenuPosition(contextMenuPosition);
|
|
setTarget(event.target as HTMLElement); // this is wrong since target can be null and not undefined
|
|
}
|
|
|
|
function OnLeftClick(): void {
|
|
setIsOpen(false);
|
|
}
|
|
|
|
getListener()?.addEventListener(
|
|
'contextmenu',
|
|
OnContextMenu
|
|
);
|
|
|
|
window.addEventListener(
|
|
'click',
|
|
OnLeftClick
|
|
);
|
|
|
|
return () => {
|
|
getListener()?.removeEventListener(
|
|
'contextmenu',
|
|
OnContextMenu
|
|
);
|
|
|
|
window.removeEventListener(
|
|
'click',
|
|
OnLeftClick
|
|
);
|
|
};
|
|
});
|
|
}
|
|
|
|
const MENU_WIDTH_CLASS = 'w-52';
|
|
const MENU_VERTICAL_PADDING_CLASS = 'py-1';
|
|
|
|
export function Menu(props: IMenuProps): JSX.Element {
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [contextMenuPosition, setContextMenuPosition] = React.useState<IPoint>({
|
|
x: 0,
|
|
y: 0
|
|
});
|
|
const [target, setTarget] = React.useState<HTMLElement>();
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
|
const [menuWidth, menuHeight] = useSize(menuRef);
|
|
|
|
UseMouseEvents(
|
|
props.getListener,
|
|
setIsOpen,
|
|
setContextMenuPosition,
|
|
setTarget
|
|
);
|
|
|
|
const children: JSX.Element[] = [];
|
|
|
|
if (target !== undefined) {
|
|
let count = 0;
|
|
count = AddClassSpecificActions(target, count, props, children);
|
|
// Add universal actions
|
|
AddUniversalActions(props, children, count, target);
|
|
}
|
|
|
|
const visible = isOpen && children.length > 0 ? 'visible opacity-1' : 'invisible opacity-0';
|
|
const isOutOfBoundHorizontally = contextMenuPosition.x + menuWidth > window.innerWidth;
|
|
const isOutOfBoundVertically = contextMenuPosition.y + menuHeight > window.innerHeight;
|
|
const finalHorizontalPosition = isOutOfBoundHorizontally ? contextMenuPosition.x - menuWidth : contextMenuPosition.x;
|
|
const finalVerticalPosition = isOutOfBoundVertically ? contextMenuPosition.y - menuWidth : contextMenuPosition.y;
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
className={`fixed context-menu ${MENU_VERTICAL_PADDING_CLASS} ${MENU_WIDTH_CLASS} ${props.className ?? ''} ${visible}`}
|
|
style={{
|
|
left: finalHorizontalPosition,
|
|
top: finalVerticalPosition
|
|
}}>
|
|
{ children }
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddClassSpecificActions(
|
|
target: HTMLElement,
|
|
count: number,
|
|
props: IMenuProps,
|
|
children: JSX.Element[]
|
|
): number {
|
|
for (const className of target.classList) {
|
|
count++;
|
|
const actions = props.actions.get(className);
|
|
|
|
// Only select action where classname matches
|
|
if (actions === undefined) {
|
|
continue;
|
|
}
|
|
|
|
actions.forEach((action, index) => {
|
|
children.push(<MenuItem
|
|
key={`contextmenu-item-${count}-${index}`}
|
|
className={'contextmenu-item'}
|
|
text={action.text}
|
|
title={action.title}
|
|
shortcut={action.shortcut}
|
|
onClick={() => action.action(target)} />);
|
|
});
|
|
children.push(<hr key={`contextmenu-hr-${count}`} className='border-slate-400' />);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function AddUniversalActions(props: IMenuProps, children: JSX.Element[], count: number, target: HTMLElement): number {
|
|
const actions = props.actions.get('');
|
|
|
|
if (actions !== undefined) {
|
|
count++;
|
|
actions.forEach((action, index) => {
|
|
children.push(<MenuItem
|
|
key={`contextmenu-item-${count}-${index}`}
|
|
className={'contextmenu-item'}
|
|
text={action.text}
|
|
title={action.title}
|
|
shortcut={action.shortcut}
|
|
onClick={() => action.action(target)} />);
|
|
});
|
|
}
|
|
|
|
return count;
|
|
}
|