feat: BdApi.ContextMenu

* Allows patching & creating context menus.
This commit is contained in:
Strencher 2022-10-09 01:06:46 +02:00
parent 5780cc23c2
commit b1cbe8b93b
2 changed files with 297 additions and 1 deletions

View File

@ -0,0 +1,287 @@
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;

View File

@ -11,6 +11,7 @@ import UI from "./ui";
import Utils from "./utils";
import Webpack from "./webpack";
import * as Legacy from "./legacy";
import ContextMenu from "./contextmenu";
const bounded = new Map();
const PluginAPI = new AddonAPI(PluginManager);
@ -18,6 +19,7 @@ const ThemeAPI = new AddonAPI(ThemeManager);
const PatcherAPI = new Patcher();
const DataAPI = new Data();
const DOMAPI = new DOM();
const ContextMenuAPI = new ContextMenu();
/**
* `BdApi` is a globally (`window.BdApi`) accessible object for use by plugins and developers to make their lives easier.
@ -39,6 +41,7 @@ export default class BdApi {
this.Patcher = new Patcher(pluginName);
this.Data = new Data(pluginName);
this.DOM = new DOM(pluginName);
this.ContextMenu = new ContextMenu();
bounded.set(pluginName, this);
}
@ -109,5 +112,11 @@ BdApi.Utils = Utils;
*/
BdApi.DOM = DOMAPI;
/**
* An instance of {@link ContextMenu} for interacting with context menus
* @type ContextMenu
*/
BdApi.ContextMenu = ContextMenuAPI;
Object.freeze(BdApi);
Object.freeze(BdApi.prototype);
Object.freeze(BdApi.prototype);