Files
2025-03-18 17:04:29 +01:00

1046 lines
36 KiB
JavaScript

// Stupid amount of backtracking to make sure we get to the root.
import { app } from "../../../../../../../../../../../../../../../../scripts/app.js";
import { api } from "../../../../../../../../../../../../../../../../scripts/api.js";
import { $el } from "../../../../../../../../../../../../../../../../scripts/ui.js";
export const settingsHelpers = {};
class CustomSettingTypes {
static generateHtmlID(name) {
return `${name
.replaceAll(" ", "")
.replaceAll("[", "")
.replaceAll("]", "-")}`;
}
static multilineSetting(name, setter, value, attrs) {
// Ensure that tooltip has a default value if not provided
const htmlID = CustomSettingTypes.generateHtmlID(name);
// Create the textarea element
const textarea = $el("textarea", {
value,
id: htmlID,
oninput: (e) => {
adjustHeight();
},
className: "p-inputtext",
style: {
width: "100%",
resize: "none",
},
});
const maxLines = attrs.maxHeight;
const offset = 16;
let lastHeight = 0;
const adjustHeight = () => {
requestAnimationFrame(() => {
const parentDiv = textarea.parentElement;
if (parentDiv != null) {
parentDiv.style.width = "100%";
const id = parentDiv.id;
if (id != null) {
api.storeSetting(id, textarea.value);
}
}
textarea.style.height = ""; // Allow to shrink
const scrollHeight = textarea.scrollHeight;
const lines = textarea.value.split("\n").length;
let height = scrollHeight;
if (lines > maxLines) {
height =
((scrollHeight - offset) / lines) * maxLines + offset;
}
textarea.setAttribute(
"style",
`width: 100%; height: ${height + 3}px; resize: none;`
);
if (scrollHeight !== lastHeight) {
if (scrollHeight > lastHeight) {
textarea.scrollTop = textarea.scrollHeight; // Scroll to bottom
}
lastHeight = scrollHeight;
}
});
};
adjustHeight();
return textarea;
}
static colorPickerSetting(name, setter, value, attrs) {
const htmlID = CustomSettingTypes.generateHtmlID(name);
// Create the color input element
const colorInput = $el("input", {
type: "color",
value, // Pre-set the value of the color picker
id: htmlID,
className: "p-inputtext",
onchange: (e) => {
const newColor = e.target.value;
setter(newColor);
},
...attrs,
});
// Styling or additional setup if necessary
colorInput.style.height = "40px";
colorInput.style.cursor = "pointer";
colorInput.style.flexGrow = "1";
colorInput.style.padding = "3px 5px";
requestAnimationFrame(() => {
const parent = colorInput.parentElement;
if (parent != null) {
parent.style.display = "contents";
}
});
return colorInput;
}
static buttonSetting(name, setter, value, attrs) {
const button = $el("button", {
textContent: attrs.text,
className: "p-inputtext",
style: {
width: "100%",
cursor: "pointer",
minHeight: "36px",
minWidth: "200px",
},
onclick: () => {
attrs.onclick();
setTimeout(() => {
button.blur();
}, 25);
},
});
return button;
}
}
/**
* A helper class to manage ComfyUI settings.
*/
export class SettingsHelper {
/**
* A dictionary of all settings added with the `SettingsHelper`.
*/
static defaultSettings = {};
getDefaultSettings() {
return SettingsHelper.defaultSettings;
}
/**
* All ComfyUI settings.
*/
static allSettings;
static settingsIdMap = {};
static debouncedEvents = {};
/**
* Creates a new SettingsHelper instance.
* @param {string} prefix - The prefix to use for all settings.
* @example
* const settingsHelper = new SettingsHelper("example");
*/
constructor(prefix) {
this.prefix = this.#formatPrefix(prefix);
this.#bindEvents();
this.#initializeDebouncedSendEvent();
SettingsHelper.debouncedEvents = {};
this.uiHelper = new UiHelper();
this.#initialize();
settingsHelpers[prefix] = this;
}
/**
* Ensures the prefix ends with a dot.
*/
#formatPrefix(prefix) {
return prefix.endsWith(".") ? prefix : `${prefix}.`;
}
/**
* Binds necessary event handlers.
*/
#bindEvents() {
SettingsHelper.PresetOnChange.reloadSettings =
SettingsHelper.PresetOnChange.reloadSettings.bind(this);
}
/**
* Initializes debounced send event functionality.
*/
#initializeDebouncedSendEvent() {
SettingsHelper.debouncedSendEvent = this.debounce((details) => {
const event = new CustomEvent(this.prefix + "reloadSettings", {
detail: {
...details,
eventSrc: "global",
},
});
window.dispatchEvent(event);
}, 250);
}
async #initialize() {
if (!SettingsHelper.allSettings) {
SettingsHelper.allSettings = await this.getAllSettings();
}
}
/**
* Enum-like object for valid setting types.
* @example
* BOOLEAN(),
* NUMBER(),
* SLIDER(min, max, step),
* COMBO(...options),
* TEXT(),
* HIDDEN(),
* // Custom setting types.
* MULTILINE(maxLines?),
* COLORPICKER(),
* BUTTON(text, onclick),
*/
static SettingsType = {
BOOLEAN() {
return { type: "boolean" };
},
NUMBER() {
return { type: "number" };
},
/**
* @param {number} min - The minimum number.
* @param {number} max - The maximum number.
* @param {number} step - The step to take when increasing or decreasing.
*/
SLIDER(min, max, step) {
return {
type: "slider",
attrs: { min: min, max: max, step: step },
};
},
/**
* @param {...any} options - A text value map of the options in the combo.
* @example
* COMBO({ text: "Combo 1", value: "combo1" }, { text: "Combo 2", value: "combo2" })
*/
COMBO(...options) {
return {
type: "combo",
options: options,
};
},
TEXT() {
return { type: "text" };
},
/**
* @param {*} maxLines - The maximum amount of lines to appear before limiting the height of the setting.
*/
MULTILINE(maxLines = 10) {
return {
type: CustomSettingTypes.multilineSetting,
attrs: { maxHeight: maxLines },
};
},
COLORPICKER() {
return { type: CustomSettingTypes.colorPickerSetting };
},
/**
* @param {string} text - The text on the button.
* @param {function} onclick - What happens when the user clicks the button.
*/
BUTTON(text, onclick) {
return {
type: CustomSettingTypes.buttonSetting,
attrs: { text: text, onclick: onclick },
};
},
HIDDEN() {
return { type: "hidden" };
},
};
static ST = SettingsHelper.SettingsType;
/**
* A collection of preset onChange functions for common settings use cases.
*/
static PresetOnChange = {
/**
* Sends out a custom event called {prefix}.reloadSettings. This function is debounced. If the setting id is passed along
* in the details like this: `details.id` it will be handled separately from other settings.
* @example
* // You can create a listener like this:
* function onSettingsReload() {
* console.log("Example");
* }
* settingsHelper.addReloadSettingsListener(onSettingsReload);
*/
reloadSettings(details) {
const id = details?.id;
if (id) {
// Check if there's already a debounced function for this id
if (!SettingsHelper.debouncedEvents[id]) {
// Create a debounced function for this id if it doesn't exist
SettingsHelper.debouncedEvents[id] = this.debounce(
(details) => {
const event = new CustomEvent(
this.prefix + "reloadSettings",
{
detail: {
...details,
eventSrc: "individual",
},
}
);
window.dispatchEvent(event);
},
250
);
}
// Call the debounced function for this specific id
SettingsHelper.debouncedEvents[id](details);
} else {
// Fallback to the global debounce if no id is provided
SettingsHelper.debouncedSendEvent(details);
}
},
};
static PC = SettingsHelper.PresetOnChange;
#slugify(str) {
str = str.replace(/^\s+|\s+$/g, ""); // trim leading/trailing white space
str = str.toLowerCase(); // convert string to lowercase
str = str
.replace(/[^a-z0-9 -]/g, "") // remove any non-alphanumeric characters
.replace(/\s+/g, "-") // replace spaces with hyphens
.replace(/-+/g, "-"); // remove consecutive hyphens
return str;
}
#generateId(name) {
return this.prefix + this.#slugify(name);
}
#registerSetting(settingDefinition) {
const extension = {
name: settingDefinition.id,
init() {
app.ui.settings.addSetting({
...settingDefinition,
});
},
};
app.registerExtension(extension);
}
/**
* Adds a new setting.
* @param {Object} settingDict - An object containing the setting properties.
* @param {string} settingDict.name - The unique name of the setting.
* @param {array} settingDict.category - An array of categories the setting belongs to.
* @param {string} settingDict.type - The type of the setting (e.g., 'boolean', 'number', 'slider', 'combo', 'text').
* @param {*} settingDict.defaultValue - The default value for the setting.
* @param {function} settingDict.onChange - A function to run when the user changes the setting.
* @param {*} settingDict.tooltip - A tooltip to show the user.
* @param {...any} settingDict... - Additional options for the setting.
* @example
* settingsHelper.addSetting({
* name: "Dark Mode",
* category: ['Example', 'Visual', 'Dark Mode'],
* type: SettingsHelper.SettingsType.BOOLEAN,
* defaultValue: false,
* onChange: (newValue, oldValue) => {
* console.log('New Value:', newValue);
* console.log('Old Value:', oldValue);
* },
* tooltip: "Toggle dark mode for the UI."
* });
*
* settingsHelper.addSetting({
* name: "Volume",
* type: SettingsHelper.SettingsType.SLIDER,
* defaultValue: 50,
* attrs: { min: 0, max: 100, step: 1 },
* tooltip: "Adjust the volume level.",
* });
*
* settingsHelper.addSetting({
* name: "Theme",
* type: SettingsHelper.SettingsType.COMBO,
* defaultValue: "Light",
* options: settingsHelper.createSettingOptions({ text: "Light", value: "light" }, "Dark"),
* onChange: (newValue, oldValue) => {
* console.log('New Value:', newValue);
* console.log('Old Value:', oldValue);
* },
* tooltip: "Choose a UI theme."
* });
*/
addSetting(settingDict) {
if (!settingDict.id) {
settingDict.id = this.#generateId(settingDict.name);
}
SettingsHelper.settingsIdMap[settingDict.name] = settingDict.id;
const settingDefinition = {
...settingDict,
// Check if 'type' is a function, and only call it if it is
...(typeof settingDict.type === "function"
? settingDict.type()
: settingDict.type),
};
SettingsHelper.defaultSettings[settingDict.id] =
settingDict.defaultValue;
this.#registerSetting(settingDefinition);
}
/**
* Adds several settings. Pass an array of settings.
* @param {Array.<Object>} settingsDefinitions - An array of setting objects.
* @param {Object} settingsDefinitions[].name - The unique name of the setting.
* @param {Array.<string>} settingsDefinitions[].category - An array of categories the setting belongs to.
* @param {Object|function} settingsDefinitions[].type - The type of the setting (either a type object or a function that returns the type object).
* @param {*} settingsDefinitions[].defaultValue - The default value for the setting.
* @param {function} settingsDefinitions[].onChange - A function to run when the user changes the setting.
* @param {string} [settingsDefinitions[].tooltip] - A tooltip to show the user
* @example
* const settingsDefinitions = [
{
name: "Boolean",
category: ["Example", "Example", "Boolean"],
defaultValue: true,
tooltip: "This is a boolean setting",
type: SettingsHelper.SettingsType.BOOLEAN,
onChange: () => SettingsHelper.PresetOnChange.reloadSettings(),
}
];
settingsHelper.addSettings(settingsDefinitions);
*/
addSettings(settingsDefinitions) {
settingsDefinitions.forEach((setting) => {
this.addSetting(setting);
});
}
/**
* Creates an array of options for 'combo' type settings.
* @param {...string|Object} options - A list of strings or objects with text and value.
* @returns {Array<Object>} An array of option objects.
*/
createSettingOptions(...options) {
return options.map((option) =>
typeof option === "string"
? { text: option, value: option }
: option
);
}
async #fetchApi(route, options = {}) {
if (!options.headers) {
options.headers = {};
}
options.cache = "no-store";
try {
const response = await fetch(route, options);
if (!response.ok) {
return null;
}
const data = await response.json();
return data;
} catch (error) {
console.error(
"There was a problem with the fetch operation:",
error
);
return null;
}
}
async #fetchSetting(name) {
const settingUrl = "/settings/" + name;
const data = await this.#fetchApi(settingUrl);
return data;
}
/**
* Get the id of a setting.
* @param {string} name - The name or the id of a setting added by settingsHelper.
* @returns The id of the setting if it exists or null if it doesn't find any.
*/
getSettingId(name) {
// Check if the "name" exists as a key in the settingsIdMap
if (SettingsHelper.settingsIdMap.hasOwnProperty(name)) {
return SettingsHelper.settingsIdMap[name];
}
// Check if the "name" is a value for any key in the settingsIdMap
for (let key in SettingsHelper.settingsIdMap) {
if (SettingsHelper.settingsIdMap[key] === name) {
return name;
}
}
// If neither condition is met, return null
return null;
}
/**
* Retrieves the value of a setting. Will return the default value if it fails.
* @param {string} name - The name of the setting to retrieve.
* @returns {Promise<*>} The value of the setting.
*/
async getSetting(name) {
name = this.getSettingId(name);
const response = await this.#fetchSetting(name);
if (response == null) {
return SettingsHelper.defaultSettings[name];
}
return response;
}
/**
* Retrieves the value of a setting. Will return the default value if it fails.
* @param {string} id - The id of the setting to retrieve.
* @returns {Promise<*>} The value of the setting.
*/
async getSettingById(id) {
const response = await this.#fetchSetting(id);
if (response == null) {
return SettingsHelper.defaultSettings[id];
}
return response;
}
/**
* Retrieves the value of a setting. Will return null if it fails or the user hasn't used the setting.
* @param {string} name - The name of the setting to retrieve.
* @returns {Promise<*>} The value of the setting.
*/
async getSettingNoDefault(name) {
return await this.#fetchSetting(name);
}
/**
* Sets the value of a setting.
* @param {string} name - The name of the setting to set.
* @param {*} value - The value of the setting.
*/
setSetting(name, value) {
// If name is a setting use the name, otherwise get the id from the generate function.
if (SettingsHelper.defaultSettings[name] == undefined) {
name = this.#generateId(name);
}
api.storeSetting(name, value);
}
/**
* Retrieves the value of multiple settings.
* @param {string} settings - Several settings to retrieve.
* @returns {Promise<*>} The value of the setting.
*/
async getMultipleSettings(...settings) {
const allSettings = await this.getAllSettings();
const settingsMap = settings.reduce((map, setting) => {
const settingName = this.getSettingId(setting);
map[setting] =
allSettings[settingName] !== undefined
? allSettings[settingName]
: SettingsHelper.defaultSettings[settingName];
return map;
}, {});
return settingsMap;
}
/**
* Get all ComfyUI settings.
* @returns
*/
async getAllSettings() {
const allSettings = await this.#fetchApi("/settings");
SettingsHelper.allSettings = allSettings;
return allSettings;
}
/**
* Adds an event listener for the custom reloadSettings event.
* @param {function} callback - The function to run when the reloadSettings event is triggered.
*/
addReloadSettingsListener(callback) {
const eventName = this.prefix + "reloadSettings";
window.addEventListener(eventName, (event) => {
callback(event);
});
}
/**
* Creates a debounced function that delays the invocation of `func` until after `delay` milliseconds
* have elapsed since the last time the debounced function was invoked.
* @param {function} func - The function to run after the delay.
* @param {number} delay - The delay in milliseconds to wait before running the function.
* @returns {function} A debounced version of the original function that delays its execution.
*/
debounce(func, delay) {
let debounceTimer;
return function () {
const context = this;
const args = arguments;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => func.apply(context, args), delay);
};
}
/**
* Creates a debounced function that only allows the function to be invoked on the leading edge
* of the `wait` period. The function will only be called again after the `wait` period has passed.
* @param {function} func - The function to run.
* @param {number} wait - The time in milliseconds to wait before allowing another invocation.
* @returns {function} A debounced function that triggers on the leading edge of the `wait` period.
*/
leadingEdgeDebounce(func, wait) {
let timeout;
let lastCallTime = 0;
return function (...args) {
const now = Date.now();
// If the last call was longer ago than the wait period, reset the timeout
if (now - lastCallTime > wait) {
lastCallTime = now;
func.apply(this, args); // Call the function immediately
}
clearTimeout(timeout); // Clear any previous timeout
// Set a new timeout that will reset `lastCallTime` after the wait period
timeout = setTimeout(() => {
lastCallTime = 0;
}, wait);
};
}
async fetchApi(route, options) {
if (!options) {
options = {};
}
if (!options.headers) {
options.headers = {};
}
options.cache = "no-store";
try {
const response = await fetch(route, options);
const data = await response.json();
return data;
} catch (error) {
console.error(
"There was a problem with the fetch operation:",
error
);
}
}
/**
* Generates a markdown table of settings organized by categories from a settings definitions array.
* @param {Array} settingsDefinitions - An array of setting definition objects
* @returns {string} A markdown formatted table of settings.
* @example
* const markdownTable = settingsHelper.generateSettingsMarkdownTable(settingsDefinitions);
* console.log(markdownTable);
*/
generateSettingsMarkdownTable(settingsDefinitions) {
if (
!Array.isArray(settingsDefinitions) ||
settingsDefinitions.length === 0
) {
return "No settings definitions provided.";
}
// Group settings by category
const settingsByCategory = {};
for (const setting of settingsDefinitions) {
// Skip settings without a name
if (!setting.name) continue;
// Get the main category (first item in the category array) or use "Uncategorized"
const category =
setting.category && setting.category.length > 0
? setting.category[1]
: "Uncategorized";
// Initialize category if it doesn't exist
if (!settingsByCategory[category]) {
settingsByCategory[category] = [];
}
// Determine the type string based on the setting type
let typeStr = "Unknown";
if (typeof setting.type === "function") {
// Try to infer type from the function name or structure
const typeObj = setting.type();
if (typeObj.type === "boolean") typeStr = "Boolean";
else if (typeObj.type === "number") typeStr = "Number";
else if (typeObj.type === "text") typeStr = "Text";
else if (typeObj.type === "slider" && typeObj.attrs)
typeStr = `Slider (${typeObj.attrs.min}-${typeObj.attrs.max})`;
else if (typeObj.type === "combo" && typeObj.options)
typeStr = `Combo (${typeObj.options
.map((o) => o.text || o)
.join("/")})`;
else if (typeObj.type === CustomSettingTypes.multilineSetting)
typeStr = "Multiline text";
else if (typeObj.type === CustomSettingTypes.colorPickerSetting)
typeStr = "Color picker";
else if (typeObj.type === CustomSettingTypes.buttonSetting)
typeStr = "Button";
else typeStr = String(typeObj.type);
} else if (
typeof setting.type === "object" &&
setting.type !== null
) {
// Direct type object
if (setting.type.type === "boolean") typeStr = "Boolean";
else if (setting.type.type === "number") typeStr = "Number";
else if (setting.type.type === "text") typeStr = "Text";
else if (setting.type.type === "slider" && setting.type.attrs)
typeStr = `Slider (${setting.type.attrs.min}-${setting.type.attrs.max})`;
else if (setting.type.type === "combo" && setting.type.options)
typeStr = `Combo (${setting.type.options
.map((o) => o.text || o)
.join("/")})`;
else if (
setting.type.type === CustomSettingTypes.multilineSetting
)
typeStr = "Multiline text";
else if (
setting.type.type === CustomSettingTypes.colorPickerSetting
)
typeStr = "Color picker";
else if (setting.type.type === CustomSettingTypes.buttonSetting)
typeStr = "Button";
else typeStr = String(setting.type.type);
} else if (typeof setting.type === "string") {
typeStr = setting.type;
}
// Get description from tooltip if available
const description = setting.tooltip || "";
settingsByCategory[category].push({
name: setting.name,
type: typeStr,
description,
});
}
// Generate markdown table
let markdown = "";
for (const [category, settings] of Object.entries(settingsByCategory)) {
markdown += `### ${category}\n\n`;
markdown += "| Setting Name | Type | Description |\n";
markdown += "| ------------ | ---- | ----------- |\n";
for (const setting of settings) {
markdown += `| ${setting.name} | ${setting.type} | ${setting.description} |\n`;
}
markdown += "\n";
}
return markdown;
}
}
export class UiHelper {
static contextMenuNames = [];
/**
* Creates a new UiHelper instance.
* @example
* const uiHelper = new UiHelper();
*/
constructor() {
this.comfyLoaded = false;
}
/**
* Wait until ComfyUI is loaded. Useful when using api, will immediately return if comfy is loaded.
* @example
* await uiHelper.waitForComfy();
*/
async waitForComfy() {
// If comfy is already loaded, resolve immediately
if (this.comfyLoaded) {
return Promise.resolve(true);
}
// Otherwise, wait for the app to load
return new Promise((resolve) => {
const interval = setInterval(() => {
if (window.app) {
clearInterval(interval); // Stop checking once the app is ready
this.comfyLoaded = true;
resolve(true); // Resolve the promise
}
}, 500); // Check every 500ms
});
}
/**
* @returns {string} Returns the version of the frontend.
*/
getComfyVersion() {
return window["__COMFYUI_FRONTEND_VERSION__"];
}
/**
* Enum-like object for valid severity levels.
*/
static Severity = {
SUCCESS: "success",
INFO: "info",
WARNING: "warn",
ERROR: "error",
};
static S = UiHelper.Severity;
/**
* Create a popup in the top right. If you don't include life it will stay until the user removes it.
* @param {Severity} severity - Severity of the popup, use `UiHelper.Severity`.
* @param {string} title - Title of the popup.
* @param {*} detail - Detailed message.
* @param {number} life - Millisecond lifetime of the popup.
* @example
* uiHelper.addToast(
* uiHelper.Severity.WARNING,
* "Settings",
* "Updated settings.",
* 2000
* )
*/
addToast(severity, title, detail, life = null) {
app.extensionManager.toast.add({
severity: severity,
summary: title,
detail: detail,
life: life,
});
}
addSideBarTab(id, icon, title, tooltip, type, render) {
app.extensionManager.registerSidebarTab({
id: id,
icon: icon,
title: title,
tooltip: tooltip,
type: type,
render: render,
});
}
/**
* Enum-like object for preset index functions.
*/
static PresetInsertIndex = {
/**
* Find the index of a given option string in the menu. The dividers are `null`.
* @param {*} optionContent - The content to search for (can be a string or null).
* @param {number} occurrence - The occurrence number to find (default is 1 for the first occurrence).
* @returns {function} A function that takes options and returns the index of the matching option.
*/
aboveOption(optionContent, occurrence = 1) {
return function (options) {
let matchCount = 0;
// Find the index of the nth occurrence of the option that matches the specified content or is null
for (let i = 0; i < options.length; i++) {
const option = options[i];
// Case 1: If optionContent is null, look for null options
if (optionContent === null && option === null) {
matchCount++;
if (matchCount === occurrence) {
return i;
}
}
// Case 2: If optionContent is a string, compare it with option.content (if option is not null)
if (
option &&
option.content &&
option.content === optionContent
) {
matchCount++;
if (matchCount === occurrence) {
return i;
}
}
}
// If no matching occurrence is found, return -1 (append at the end)
return -1;
};
},
/**
* Find the index just below a given option string in the menu. The dividers are `null`.
* @param {*} optionContent - The content to search for (can be a string or null).
* @param {number} occurrence - The occurrence number to find (default is 1 for the first occurrence).
* @returns {function} A function that takes options and returns the index just after the matching option.
*/
underOption(optionContent, occurrence = 1) {
return function (options) {
const indexAbove = UiHelper.PresetInsertIndex.aboveOption(
optionContent,
occurrence
)(options);
// Return the index after the found index, or -1 if no match is found (which appends to the end)
return indexAbove !== -1 ? indexAbove + 1 : -1;
};
},
/**
* Always insert at the end of the menu.
* @returns {function} - A function that returns -1.
*/
atEnd() {
return function (options) {
return -1; // Always return -1 to append at the end
};
},
/**
* Always insert at the beginning
* @returns {function} A function that returns 0.
*/
atStart() {
return function (options) {
return 0; // Insert at the start (index 0)
};
},
};
static PI = UiHelper.PresetInsertIndex;
#registerContextMenu(name, nodeType, menuItem, insertIndex) {
app.registerExtension({
name,
async setup() {
const original_getNodeMenuOptions =
app.canvas.getNodeMenuOptions;
app.canvas.getNodeMenuOptions = function (node) {
const options = original_getNodeMenuOptions.apply(
this,
arguments
);
if (node.type === nodeType) {
let index =
typeof insertIndex === "function"
? insertIndex(options, node)
: insertIndex;
if (index !== -1) {
options.splice(index, 0, menuItem);
} else {
options.push(menuItem);
}
}
return options;
};
},
});
}
/**
* Registers a context menu for a specific node type within the app's canvas.
*
* @param {string} nodeType - The type of node for which the context menu will be applied.
* @param {Object} menuItem - The menu item to be added to the context menu. This object can be made with the `createContextMenuItem()` or `createContextMenuGroup()` function.
* @param {(number|function)} insertIndex - The index at which the menu item should be inserted.
* If a number is provided, it will be used as the index for insertion.
* If a function is provided, it should return an index based on the current context (e.g., options and node).
* The function signature is `(options: Array, node: Object) => number`. There are several preset functions in `uiHelper.PresetInsertIndex`
* @example
* const menuItem = uiHelper.createContextMenuItem("example", false, () => console.log("example"));
*
* uiHelper.addContextMenu("Example Node", menuItem, UiHelper.PresetInsertIndex.aboveOption("Title"));
* uiHelper.addContextMenu("Example Node", menuItem, UiHelper.PresetInsertIndex.underOption(null, 2));
*/
addContextMenu(nodeType, menuItem, insertIndex) {
let baseName = nodeType + ".contextMenu";
let name = baseName;
let counter = 1;
// Check if the name already exists in contextMenuNames and append a number if needed
while (UiHelper.contextMenuNames.includes(name)) {
name = `${baseName}_${counter}`;
counter++;
}
// Once a unique name is found, push it into the contextMenuNames array
UiHelper.contextMenuNames.push(name);
// Proceed with registering the context menu
this.#registerContextMenu(name, nodeType, menuItem, insertIndex);
}
/**
* Creates a context menu item.
*
* @param {string} content - The label for the context menu item.
* @param {boolean} disabled - A boolean indicating whether the menu item is disabled.
* @param {function} callback - The function to be executed when the menu item is clicked.
* @returns {Object} A context menu item object to be used in the context menu.
*/
createContextMenuItem(content, disabled, callback) {
return {
content: content,
disabled: disabled,
callback: callback,
};
}
/**
* Creates a context menu group (submenu).
*
* @param {string} name - The name of the menu group.
* @param {...Object} menuItems - A variable number of context menu items to be included in the group. Can be made with the `createContextMenuItem()` function.
* @returns {Object} A context menu group object with the provided items as a submenu.
*/
createContextMenuGroup(name, ...menuItems) {
return {
content: name,
disabled: false,
has_submenu: true,
submenu: {
options: menuItems,
},
};
}
}