birch-context-menu
Version:
Custom context menu for react
258 lines (257 loc) • 10.7 kB
JavaScript
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { useRef, useState, useEffect, useCallback, createContext, useContext } from 'react';
import { EventEmitter } from 'birch-event-emitter';
import { StyledMenu, StyledMenuButton, StyledMenuItem, StyledMenuGroup, StyledSubMenu } from '../components';
const MenuReactContext = createContext(null);
export const useContextMenu = () => useContext(MenuReactContext);
export const ContextMenuProvider = ({ children }) => {
/* private instance fields */
const lastCapturedCtxMenuEvent = useRef();
/* public context fields */
const pos = useRef({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
const [data, setData] = useState([]);
const emitter = useRef(new EventEmitter());
/* private methods */
const captureCtxMenuEvent = useCallback((ev) => {
lastCapturedCtxMenuEvent.current = ev;
}, []);
useEffect(() => {
addEventListener('contextmenu', captureCtxMenuEvent, true);
return () => {
removeEventListener('contextmenu', captureCtxMenuEvent, true);
};
}, []);
const validateData = (data, parentItemDebugInfo = '') => {
const err = 'ContextMenuData must be an array of MenuItemGroup[]';
if (!Array.isArray(data)) {
throw new TypeError(err);
}
data.forEach((group, groupIdx) => {
if (!Array.isArray(group)) {
throw new TypeError(err);
}
group.forEach((item, itemIdx) => {
const itemDebugInfo = parentItemDebugInfo === ''
? ''
: `${parentItemDebugInfo} <submenu> ` +
`MenuItemGroup #${groupIdx} => MenuItem #${itemIdx}`;
if (!item.label) {
throw new TypeError(`Missing "label" prop on MenuItem in ${itemDebugInfo}`);
}
const isMenuItem = item.onClick;
const isSubMenuItem = item.submenu;
if (!isMenuItem && !isSubMenuItem) {
throw new TypeError(`MenuItem must have either onClick handler or be a SubMenuItem with submenu prop. Check ${itemDebugInfo}`);
}
if (isMenuItem && isSubMenuItem) {
throw new TypeError(`MenuItem can either have onClick handler or be a SubMenuItem with submenu prop, not both. Check ${itemDebugInfo}`);
}
if (isSubMenuItem) {
validateData(item.submenu, itemDebugInfo);
}
});
});
};
/* public context methods */
const showContextMenu = (data, newpos) => {
hideContextMenu();
let handleActive = true;
if (lastCapturedCtxMenuEvent.current &&
!lastCapturedCtxMenuEvent.current.defaultPrevented) {
lastCapturedCtxMenuEvent.current.preventDefault();
}
const disposeHandle = () => {
handleActive = false;
emitter.current.clear(EnumContextMenuEvent.Show);
emitter.current.clear(EnumContextMenuEvent.Close);
};
emitter.current.on(EnumContextMenuEvent.Close, disposeHandle);
void Promise.resolve(data).then((ctxMenuData) => {
validateData(ctxMenuData);
pos.current =
newpos && newpos.x > -1 && newpos.y > -1
? newpos
: lastCapturedCtxMenuEvent.current
? {
x: lastCapturedCtxMenuEvent.current.clientX,
y: lastCapturedCtxMenuEvent.current.clientY
}
: { x: 0, y: 0 };
ReactDOM.unstable_batchedUpdates(() => {
setVisible(true);
setData(ctxMenuData);
emitter.current.emit(EnumContextMenuEvent.Show);
});
});
return {
onShow: (cb) => {
if (handleActive) {
emitter.current.on(EnumContextMenuEvent.Show, cb);
}
},
onClose: (cb) => {
if (handleActive) {
emitter.current.on(EnumContextMenuEvent.Close, cb);
}
},
update: (newData) => {
if (handleActive) {
validateData(newData);
setData(newData);
}
},
close: () => {
if (handleActive) {
hideContextMenu();
}
},
isActive: () => handleActive
};
};
const hideContextMenu = () => {
if (!visible) {
return;
}
setVisible(false);
emitter.current.emit(EnumContextMenuEvent.Close);
};
/* main render */
return (React.createElement(MenuReactContext.Provider, { value: {
showContextMenu,
hideContextMenu,
pos: pos.current,
visible,
data
} }, children));
};
var EnumContextMenuEvent;
(function (EnumContextMenuEvent) {
EnumContextMenuEvent[EnumContextMenuEvent["Show"] = 1] = "Show";
EnumContextMenuEvent[EnumContextMenuEvent["Close"] = 2] = "Close";
})(EnumContextMenuEvent || (EnumContextMenuEvent = {}));
export const ContextMenu = (_props) => {
const { pos, visible, data, hideContextMenu } = useContext(MenuReactContext);
const rootContextMenu = useRef();
const isLastMouseDownOnMenu = useRef(false);
const onMouseDownAnywhere = useCallback(() => {
if (!isLastMouseDownOnMenu.current) {
hideContextMenu();
}
isLastMouseDownOnMenu.current = false;
}, [hideContextMenu]);
const renderMenu = (data, submenu = false) => {
const menu = [];
data.forEach((menuGroup, _i) => {
if (!menuGroup) {
return;
}
let groupHash = ``;
const items = menuGroup.map((item) => {
if (!item) {
return null;
}
groupHash += item.label;
if (item.onClick) {
const onClick = (_e) => {
if (!item.disabled) {
;
item.onClick();
hideContextMenu();
}
};
return (React.createElement(StyledMenuItem, { key: item.label, className: `menu-item ${item.disabled ? 'disabled' : ''}` },
React.createElement(StyledMenuButton, { onClick: onClick, onMouseEnter: hideSubMenu },
React.createElement("span", { className: "label" }, item.label),
React.createElement("span", { className: "label sublabel" }, item.sublabel || ''))));
}
if (item.submenu) {
return (React.createElement(StyledMenuItem, { key: item.label, className: `menu-item submenu-item ${item.disabled ? 'disabled' : ''}` },
renderMenu(item.submenu, true),
React.createElement(StyledMenuButton, { onMouseEnter: showSubMenu },
React.createElement("span", { className: "label" }, item.label),
React.createElement("i", { className: "submenu-expand" }))));
}
return null;
});
menu.push(React.createElement(StyledMenuGroup, { key: groupHash }, items));
});
return submenu ? (React.createElement(StyledSubMenu, null, menu)) : (React.createElement(StyledMenu, { onMouseDown: (e) => {
isLastMouseDownOnMenu.current = true;
e.stopPropagation();
}, ref: rootContextMenu, style: { display: 'none' } }, menu));
};
const adjustContextMenuClippingAndShow = () => {
const rootMenu = rootContextMenu.current;
rootMenu.style.visibility = 'hidden';
rootMenu.style.display = 'block';
const rootMenuBox = rootMenu.getBoundingClientRect();
let { x, y } = pos;
if (y + rootMenuBox.height > innerHeight) {
y -= rootMenuBox.height;
}
if (x + rootMenuBox.width > innerWidth) {
x -= rootMenuBox.width;
}
rootMenu.style.top = `${y}px`;
rootMenu.style.left = `${x}px`;
rootMenu.style.visibility = '';
};
const showSubMenu = (ev) => {
const button = ev.currentTarget;
const li = button.parentElement;
const ulNode = li.parentElement;
if (li.classList.contains('disabled')) {
hideSubMenus(ulNode.parentElement);
return;
}
if (li.classList.contains('active')) {
return;
}
hideSubMenus(ulNode.parentElement);
li.classList.add('active');
const submenuNode = li.querySelector('div.submenu');
const parentMenuBox = ulNode.getBoundingClientRect();
submenuNode.style.display = 'block';
submenuNode.style.left = `${parentMenuBox.width}px`;
const submenuBox = submenuNode.getBoundingClientRect();
const { left, top } = submenuBox;
if (top + submenuBox.height > innerHeight) {
submenuNode.style.marginTop = `${innerHeight - (top + submenuBox.height)}px`;
}
if (left + submenuBox.width > innerWidth) {
submenuNode.style.left = `${-parentMenuBox.width}px`;
}
submenuNode.style.visibility = ``;
};
const hideSubMenu = (ev) => {
const button = ev.currentTarget;
const liNode = button.parentElement;
const ctxMenuParent = liNode.parentElement
.parentElement;
hideSubMenus(ctxMenuParent);
};
const hideSubMenus = (level) => {
if (!level) {
return;
}
level.querySelectorAll('li.submenu-item.active').forEach((el) => {
el.classList.remove('active');
const submenuNode = el.querySelector('div.submenu');
submenuNode.style.display = 'none';
});
};
useEffect(() => {
if (visible) {
adjustContextMenuClippingAndShow();
addEventListener('mousedown', onMouseDownAnywhere);
}
else {
rootContextMenu.current.style.display = 'none';
hideSubMenus(rootContextMenu.current);
removeEventListener('mousedown', onMouseDownAnywhere);
}
}, [visible]);
return renderMenu(data);
};