288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
import WebpackModules from "../webpackmodules";
|
|
import Patcher from "../patcher";
|
|
import Logger from "common/logger";
|
|
import {React} from "../modules";
|
|
|
|
const MenuComponents = (() => {
|
|
const out = {};
|
|
const componentMap = {
|
|
separator: "Separator",
|
|
checkbox: "CheckboxItem",
|
|
radio: "RadioItem",
|
|
control: "ControlItem",
|
|
groupstart: "Group",
|
|
customitem: "Item"
|
|
};
|
|
|
|
let ContextMenuIndex = null;
|
|
const ContextMenuModule = WebpackModules.getModule((m, _, id) => Object.values(m).some(v => v?.FLEXIBLE) && (ContextMenuIndex = id), {searchGetters: false});
|
|
const rawMatches = WebpackModules.require.m[ContextMenuIndex].toString().matchAll(/if\(\w+\.type===\w+\.(\w+)\).+?type:"(.+?)"/g);
|
|
|
|
out.Menu = Object.values(ContextMenuModule).find(v => v.toString().includes(".isUsingKeyboardNavigation"));
|
|
|
|
for (const [, identifier, type] of rawMatches) {
|
|
out[componentMap[type]] = ContextMenuModule[identifier];
|
|
}
|
|
|
|
return out;
|
|
})();
|
|
|
|
const ContextMenuActions = (() => {
|
|
const out = {};
|
|
|
|
const ActionsModule = WebpackModules.getModule(m => Object.values(m).some(m => typeof m === "function" && m.toString().includes("CONTEXT_MENU_CLOSE")), {searchGetters: false});
|
|
|
|
for (const key of Object.keys(ActionsModule)) {
|
|
if (ActionsModule[key].toString().includes("CONTEXT_MENU_CLOSE")) {
|
|
out.closeContextMenu = ActionsModule[key];
|
|
} else if (ActionsModule[key].toString().includes("renderLazy")) {
|
|
out.openContextMenu = ActionsModule[key];
|
|
}
|
|
}
|
|
|
|
return out;
|
|
})();
|
|
|
|
class MenuPatcher {
|
|
static MAX_PATCH_ITERATIONS = 10;
|
|
static patches = {};
|
|
static subPatches = new WeakMap();
|
|
|
|
static initialize() {
|
|
const {module, key} = (() => {
|
|
const module = WebpackModules.getModule(m => Object.values(m).some(v => typeof v === "function" && v.toString().includes("CONTEXT_MENU_CLOSE")), {searchGetters: false});
|
|
const key = Object.keys(module).find(key => module[key].length === 3);
|
|
|
|
return {module, key};
|
|
})();
|
|
|
|
Patcher.before("ContextMenuPatcher", module, key, (_, methodArguments) => {
|
|
const promise = methodArguments[1];
|
|
methodArguments[1] = async function () {
|
|
const render = await promise.apply(this, arguments);
|
|
|
|
return props => {
|
|
const res = render(props);
|
|
|
|
if (res?.props.navId) {
|
|
MenuPatcher.runPatches(res.props.navId, res, props);
|
|
} else if (typeof res?.type === "function") {
|
|
MenuPatcher.patchRecursive(res, "type");
|
|
}
|
|
|
|
return res;
|
|
};
|
|
};
|
|
});
|
|
}
|
|
|
|
static patchRecursive(target, method, iteration = 0) {
|
|
if (iteration >= this.MAX_PATCH_ITERATIONS) return;
|
|
|
|
const proxyFunction = this.subPatches.get(target[method]) ?? (() => {
|
|
const originalFunction = target[method];
|
|
function patch() {
|
|
const res = originalFunction.apply(this, arguments);
|
|
|
|
if (!res) return res;
|
|
|
|
if (res.props?.navId) {
|
|
MenuPatcher.runPatches(res.props.navId, res, arguments[0])
|
|
} else {
|
|
const layer = res.props.children ? res.props.children : res;
|
|
|
|
if (typeof layer?.type == "function") {
|
|
MenuPatcher.patchRecursive(layer, "type", ++iteration);
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
patch._originalFunction = originalFunction;
|
|
Object.assign(patch, originalFunction);
|
|
this.subPatches.set(originalFunction, patch);
|
|
|
|
return patch;
|
|
})();
|
|
|
|
target[method] = proxyFunction;
|
|
}
|
|
|
|
static runPatches(id, res, props) {
|
|
if (!this.patches[id]) return;
|
|
|
|
for (const patch of this.patches[id]) {
|
|
try {
|
|
patch(res, props);
|
|
} catch (error) {
|
|
Logger.error("ContextMenu~runPatches", `Could not run ${id} patch for`, patch, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
static patch(id, callback) {
|
|
this.patches[id] ??= new Set();
|
|
this.patches[id].add(callback);
|
|
}
|
|
|
|
static unpatch(id, callback) {
|
|
this.patches[id]?.delete(callback);
|
|
}
|
|
}
|
|
|
|
class ContextMenu {
|
|
patch(navId, callback) {
|
|
MenuPatcher.patch(navId, callback);
|
|
|
|
return () => MenuPatcher.unpatch(navId, callback);
|
|
}
|
|
|
|
unpatch(navId, callback) {
|
|
MenuPatcher.unpatch(navId, callback);
|
|
}
|
|
|
|
/**
|
|
* Builds a single menu item. The only prop shown here is the type, the rest should
|
|
* match the actual component being built. View those to see what options exist
|
|
* for each, they often have less in common than you might think.
|
|
*
|
|
* @param {object} props - props used to build the item
|
|
* @param {string} [props.type="text"] - type of the item, options: text, submenu, toggle, radio, custom, separator
|
|
* @returns {object} the created component
|
|
*
|
|
* @example
|
|
* // Creates a single menu item that prints "MENU ITEM" on click
|
|
* ContextMenu.buildItem({
|
|
* label: "Menu Item",
|
|
* action: () => {console.log("MENU ITEM");}
|
|
* });
|
|
*
|
|
* @example
|
|
* // Creates a single toggle item that starts unchecked
|
|
* // and print the new value on every toggle
|
|
* ContextMenu.buildItem({
|
|
* type: "toggle",
|
|
* label: "Item Toggle",
|
|
* checked: false,
|
|
* action: (newValue) => {console.log(newValue);}
|
|
* });
|
|
*/
|
|
buildItem(props) {
|
|
const {type} = props;
|
|
if (type === "separator") return React.createElement(MenuComponents.Separator);
|
|
|
|
let Component = MenuComponents.Item;
|
|
if (type === "submenu") {
|
|
if (!props.children) props.children = this.buildMenuChildren(props.render || props.items);
|
|
}
|
|
else if (type === "toggle" || type === "radio") {
|
|
Component = type === "toggle" ? MenuComponents.CheckboxItem : MenuComponents.RadioItem;
|
|
if (props.active) props.checked = props.active;
|
|
}
|
|
else if (type === "control") {
|
|
Component = MenuComponents.ControlItem;
|
|
}
|
|
if (!props.id) props.id = `${props.label.replace(/^[^a-z]+|[^\w-]+/gi, "-")}`;
|
|
if (props.danger) props.color = "colorDanger";
|
|
if (props.onClick && !props.action) props.action = props.onClick;
|
|
props.extended = true;
|
|
|
|
return React.createElement(Component, props);
|
|
}
|
|
|
|
/**
|
|
* Creates the all the items **and groups** of a context menu recursively.
|
|
* There is no hard limit to the number of groups within groups or number
|
|
* of items in a menu.
|
|
* @param {Array<object>} setup - array of item props used to build items. See {@link ContextMenu.buildItem}
|
|
* @returns {Array<object>} array of the created component
|
|
*
|
|
* @example
|
|
* // Creates a single item group item with a toggle item
|
|
* ContextMenu.buildMenuChildren([{
|
|
* type: "group",
|
|
* items: [{
|
|
* type: "toggle",
|
|
* label: "Item Toggle",
|
|
* active: false,
|
|
* action: (newValue) => {console.log(newValue);}
|
|
* }]
|
|
* }]);
|
|
*
|
|
* @example
|
|
* // Creates two item groups with a single toggle item each
|
|
* ContextMenu.buildMenuChildren([{
|
|
* type: "group",
|
|
* items: [{
|
|
* type: "toggle",
|
|
* label: "Item Toggle",
|
|
* active: false,
|
|
* action: (newValue) => {
|
|
* console.log(newValue);
|
|
* }
|
|
* }]
|
|
* }, {
|
|
* type: "group",
|
|
* items: [{
|
|
* type: "toggle",
|
|
* label: "Item Toggle",
|
|
* active: false,
|
|
* action: (newValue) => {
|
|
* console.log(newValue);
|
|
* }
|
|
* }]
|
|
* }]);
|
|
*/
|
|
buildMenuChildren(setup) {
|
|
const mapper = s => {
|
|
if (s.type === "group") return buildGroup(s);
|
|
return this.buildItem(s);
|
|
};
|
|
const buildGroup = function(group) {
|
|
const items = group.items.map(mapper).filter(i => i);
|
|
return React.createElement(MenuComponents.Group, null, items);
|
|
};
|
|
return setup.map(mapper).filter(i => i);
|
|
}
|
|
|
|
/**
|
|
* Creates the menu *component* including the wrapping `ContextMenu`.
|
|
* Calls {@link ContextMenu.buildMenuChildren} under the covers.
|
|
* Used to call in combination with {@link ContextMenu.open}.
|
|
* @param {Array<object>} setup - array of item props used to build items. See {@link ContextMenu.buildMenuChildren}
|
|
* @returns {function} the unique context menu component
|
|
*/
|
|
buildMenu(setup) {
|
|
return (props) => {return React.createElement(MenuComponents.Menu, props, this.buildMenuChildren(setup));};
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {MouseEvent} event - The context menu event. This can be emulated, requires target, and all X, Y locations.
|
|
* @param {function} menuComponent - Component to render. This can be any react component or output of {@link ContextMenu.buildMenu}
|
|
* @param {object} config - configuration/props for the context menu
|
|
* @param {string} [config.position="right"] - default position for the menu, options: "left", "right"
|
|
* @param {string} [config.align="top"] - default alignment for the menu, options: "bottom", "top"
|
|
* @param {function} [config.onClose] - function to run when the menu is closed
|
|
* @param {boolean} [config.noBlurEvent=false] - No clue
|
|
*/
|
|
open(event, menuComponent, config) {
|
|
return ContextMenuActions.openContextMenu(event, function(e) {
|
|
return React.createElement(menuComponent, Object.assign({}, e, {onClose: ContextMenuActions.closeContextMenu}));
|
|
}, config);
|
|
}
|
|
|
|
/**
|
|
* Closes the current opened context menu immediately.
|
|
*/
|
|
close() {ContextMenuActions.closeContextMenu();}
|
|
}
|
|
|
|
Object.assign(ContextMenu.prototype, MenuComponents);
|
|
Object.freeze(ContextMenu);
|
|
Object.freeze(ContextMenu.prototype);
|
|
MenuPatcher.initialize();
|
|
|
|
export default ContextMenu;
|