From 1b339cb7621d4d07f31e9fc54d8e0e9aa5bf00db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20=20Sk=C3=B6ld?= Date: Fri, 24 Jan 2025 18:40:18 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 11 + __init__.py | 75 +++ sn0w.py | 534 ++++++++++++++++ web/images/context-menu-images-here | 0 web/js/favorite.js | 391 ++++++++++++ web/js/settings.js | 40 ++ web/js/settings/ComfyHelper.js | 933 ++++++++++++++++++++++++++++ web/js/settings/LICENSE | 21 + web/js/textHighlight.js | 550 ++++++++++++++++ web/js/util.js | 34 + 11 files changed, 2591 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 sn0w.py create mode 100644 web/images/context-menu-images-here create mode 100644 web/js/favorite.js create mode 100644 web/js/settings.js create mode 100644 web/js/settings/ComfyHelper.js create mode 100644 web/js/settings/LICENSE create mode 100644 web/js/textHighlight.js create mode 100644 web/js/util.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aad1af --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ + +__pycache__ +*.pyc +/.vscode +web/images/*.png +web/images/*.jpg +web/images/*.jpeg +web/images/*.gif +web/images/*.webp +web/images/images.json +web/images/*/* diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..18f3f56 --- /dev/null +++ b/__init__.py @@ -0,0 +1,75 @@ +import json +import os +from pathlib import Path +import folder_paths +from server import PromptServer +from aiohttp import web + +WEB_DIRECTORY = "./web" +API_PREFIX = "/SyntaxHighlighter" + + +def index_images(): + """ + Create a JSON index of all images in the web/images directory and its subfolders. + """ + try: + # Get the script's directory and construct path to images using Path + script_dir = Path(os.path.dirname(os.path.realpath(__file__))) + images_dir = script_dir.joinpath("web", "images") + + # Ensure directory exists + if not images_dir.exists(): + print(f"Warning: Directory not found: {images_dir}") + return False + + # Get all image files recursively + image_files = [] + for ext in [".png", ".jpg", ".jpeg", ".webp"]: + image_files.extend(images_dir.rglob(f"*{ext}")) + + # Create image entries + images = [] + for img_path in image_files: + images.append( + { + "filename": img_path.stem, + "path": str(img_path.relative_to(script_dir)).replace("web", "extensions\\ComfyUI-Sn0w-Scripts"), + } + ) + + # Create output data + output = {"images": images, "count": len(images)} + + # Write JSON file + json_path = images_dir.joinpath("images.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(output, f, indent=2) + + print(f"Created index with {len(images)} images at {json_path}") + return True + + except Exception as e: + print(f"Error creating image index: {str(e)}") + return False + + +# Create image index +index_images() + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/loras") +async def get_loras(request): + loras = folder_paths.get_filename_list("loras") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras))) + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/embeddings") +async def get_embeddings(request): + embeddings = folder_paths.get_filename_list("embeddings") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings))) + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/enabled") +async def get_enabled(request): + return web.json_response({"enabled": True}) diff --git a/sn0w.py b/sn0w.py new file mode 100644 index 0000000..16ae241 --- /dev/null +++ b/sn0w.py @@ -0,0 +1,534 @@ +""" +Module for handling configuration, logging, and various utility functions for the ComfyUI-Sn0w-Scripts project. + +Classes: + ConfigReader: Handles reading and managing configuration settings. + Logger: Logs messages with different severity levels and colors. + Utility: Provides static methods for common operations. + MessageHolder: Manages communication between the JavaScript frontend and Python backend. +""" + +import os +import json +import time +import torch + +from server import PromptServer +from aiohttp import web + +import folder_paths + + +class ConfigReader: + """ + Handles reading and managing configuration settings for ComfyUI without using the API. + + Methods: + get_setting(setting_id, default) + Retrieve a setting value from the configuration file. + + is_comfy_portable() + Check if comfy is running in portable mode. + """ + + DEFAULT_PATH = os.path.abspath(os.path.join(os.getcwd(), "user/default/comfy.settings.json")) + PORTABLE_PATH = os.path.abspath(os.path.join(os.getcwd(), "ComfyUI/user/default/comfy.settings.json")) + + portable = None + + @classmethod + def print_sn0w(cls, message, color="\033[0;35m"): + """Print a message with a specific color prefix.""" + print(f"{color}[sn0w] \033[0m{message}") + + @classmethod + def _get_path(cls): + return ConfigReader.PORTABLE_PATH if ConfigReader.portable else ConfigReader.DEFAULT_PATH + + @classmethod + def is_comfy_portable(cls): + """Check if the application is running in portable mode.""" + if cls.portable is not None: + return cls.portable + + # Check if default exists + if os.path.isfile(cls.DEFAULT_PATH): + cls.portable = False + ConfigReader.print_sn0w("Running standalone comfy.") + return False + + # Check if portable exists + if os.path.isfile(cls.PORTABLE_PATH): + cls.portable = True + ConfigReader.print_sn0w("Running portable comfy.") + return True + + # If neither exist + ConfigReader.print_sn0w("Could not find comfy settings.") + return None + + @staticmethod + def get_setting(setting_id, default=None): + """Retrieve a setting value from the configuration file.""" + # Determine the correct path based on the portable attribute + if ConfigReader.portable is None: + ConfigReader.print_sn0w( + f"Local configuration file not found at either {ConfigReader.PORTABLE_PATH} or {ConfigReader.DEFAULT_PATH}.", + "\033[0;33m", + ) + return default + + path = ConfigReader._get_path() + + # Try to read the settings from the determined path + try: + with open(path, "r", encoding="utf-8") as file: + settings = json.load(file) + return settings.get(setting_id, default) + except FileNotFoundError: + ConfigReader.print_sn0w(f"Local configuration file not found at {path}.", "\033[0;33m") + except json.JSONDecodeError: + ConfigReader.print_sn0w(f"Error decoding JSON from {path}.", "\033[0;31m") + + return default + + @staticmethod + def set_setting(setting_id: str, value): + """Set a setting value in the configuration file.""" + # Determine the correct path based on the portable attribute + if ConfigReader.portable is None: + ConfigReader.print_sn0w( + f"Local configuration file not found at either {ConfigReader.PORTABLE_PATH} or {ConfigReader.DEFAULT_PATH}.", + "\033[0;33m", + ) + return False + + path = ConfigReader._get_path() + + # Try to read the existing settings from the determined path + try: + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as file: + settings = json.load(file) + else: + settings = {} # If the file doesn't exist, start with an empty dict + + # If value is None, remove the setting if it exists + if value is None: + if setting_id in settings: + del settings[setting_id] + else: + # Update the setting value + settings[setting_id] = value + + # Write the updated settings back to the file + with open(path, "w", encoding="utf-8") as file: + json.dump(settings, file, indent=4) + + return True + + except FileNotFoundError: + ConfigReader.print_sn0w(f"Local configuration file not found at {path}.", "\033[0;33m") + except json.JSONDecodeError: + ConfigReader.print_sn0w(f"Error decoding JSON from {path}.", "\033[0;31m") + except IOError as e: + ConfigReader.print_sn0w(f"Error writing to {path}: {e}", "\033[0;31m") + + return False + + @classmethod + def validate_settings(cls): + """Update to new settings values after frontend update.""" + conversion_map = { + "sn0w.CustomLoraLoaders1.5": "sn0w.CustomLoraLoaders", + "sn0w.CustomLoraLoadersXL": "sn0w.CustomLoraLoaders.XL", + "sn0w.CustomLoraLoaders3": "sn0w.CustomLoraLoaders.3", + "sn0w.TextboxColors": "sn0w.TextboxSettings", + "sn0w.TextboxGradientColors": "sn0w.TextboxSettings.GradientColors", + "sn0w.SortLorasBy": "sn0w.LoraSettings.SortLorasBy", + "sn0w.RemoveLoraPath": "sn0w.LoraSettings.RemoveLoraPath", + "sn0w.LoraFolderMinDistance": "sn0w.LoraSettings.LoraFolderMinDistance", + "sn0w.DisableDefaultCharacters": "sn0w.CharacterSettings.DisableDefaultCharacters", + "sn0w.ExcludedRandomCharacters": "sn0w.CharacterSettings.ExcludedRandomCharacters", + "sn0w.SortCharactersBy": "sn0w.CharacterSettings.SortCharactersBy", + } + + path = ConfigReader._get_path() + with open(path, "r", encoding="utf-8") as file: + settings = json.load(file) + + for setting in settings: + if setting in conversion_map: + setting_value = cls.get_setting(setting) + if setting_value: + cls.set_setting(conversion_map[setting], setting_value) + cls.set_setting(setting, None) # Remove the old setting + + +# Initialize portable check when the class is defined +ConfigReader.is_comfy_portable() +ConfigReader.validate_settings() + + +class Logger: + """ + Logger class for printing messages with different severity levels and colors. + + Logger levels: + - EMERGENCY + - ALERT + - CRITICAL + - ERROR + - WARNING + - INFORMATIONAL + - DEBUG + + Methods: + log(message, level) + Log a message with a specified severity level. + + print_sigmas_differences(name, sigmas) + Prints sigmas to terminal. + """ + + PURPLE_TEXT = "\033[0;35m" + RED_TEXT = "\033[0;31m" + YELLOW_TEXT = "\033[0;33m" + GREEN_TEXT = "\033[0;32m" + RESET_TEXT = "\033[0m" + PREFIX = "[sn0w] " + ALWAYS_LOG = ["EMERGENCY", "ALERT", "CRITICAL", "ERROR"] + + enabled_levels = ConfigReader.get_setting("sn0w.LoggingLevel", ["INFORMATIONAL", "WARNING"]) + + @classmethod + def reload_config(cls): + """Reload the logger configuration settings.""" + cls.enabled_levels = ConfigReader.get_setting("sn0w.LoggingLevel", ["INFORMATIONAL", "WARNING"]) + + @classmethod + def _print_sn0w(cls, message, color): + """Print a message with a specific color prefix.""" + print(f"{color}{cls.PREFIX}{cls.RESET_TEXT}{message}") + + def log(self, message, level="ERROR"): + """Log a message with a specified severity level.""" + # Determine the color based on the type of message + if level.upper() in self.ALWAYS_LOG: + color = self.RED_TEXT + elif level.upper() == "WARNING": + color = self.YELLOW_TEXT + else: + color = self.PURPLE_TEXT # Default color + + # Check if the message's level is in the enabled log levels + if level.upper() in self.enabled_levels or level.upper() in self.ALWAYS_LOG: + self._print_sn0w(message, color) + + def print_sigmas_differences(self, name, sigmas): + """ + Takes a tensor of sigmas and prints each sigma along with the difference to the next sigma + and the percentage difference to the next sigma. + + Args: + sigmas (torch.Tensor): A 1D tensor of sigmas with a zero appended at the end. + """ + if "DEBUG" in self.enabled_levels: + self._print_sn0w(f"Scheduler: {name}", self.PURPLE_TEXT) + print("Index | Sigma Value | Difference to Next | % Difference") + print("-" * 65) + + # Compute the differences + differences = sigmas[1:] - sigmas[:-1] + + # Iterate over sigmas and differences + for i in range(len(sigmas) - 1): + if sigmas[i] != 0: + percent_diff = (differences[i] / sigmas[i]) * 100 + else: + percent_diff = float("inf") # To handle division by zero in a meaningful way + print(f"{i:<5} | {sigmas[i]:<11.4f} | {differences[i]:<18.4f} | {percent_diff:<12.2f}") + + # Print the last sigma value without a difference (since it's the appended zero) + print(f"{len(sigmas) - 1:<5} | {sigmas[-1]:<11.4f} | {'N/A':<18} | {'N/A':<12}") + + +class Utility: + """ + Utility class providing various static methods for common operations. + + Methods: + levenshtein_distance(s1, s2): + Calculate the Levenshtein distance between two strings. + + image_batch(**kwargs): + Concatenate and batch multiple image tensors. + + get_model_type(model_patcher): + Retrieve the type of the model from the model patcher. + + get_model_type_simple(model_patcher): + Retrieve a standardized model type from the model patcher. + + put_favourite_on_top(setting, arr): + Prioritize favorite items in a list based on a setting. + + create_setting_entry(setting_type, setting_value): + Create a setting entry based on the type and value. + + get_node_output(data, node_id, output_id): + Get the output of a node from the workflow data. + """ + + logger = Logger() + ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + + @staticmethod + def levenshtein_distance(s1, s2): + """Calculate the Levenshtein distance between two strings.""" + if len(s1) < len(s2): + return Utility.levenshtein_distance(s2, s1) + + if len(s2) == 0: + return len(s1) + + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + @staticmethod + def _check_image_dimensions(tensors, names): + """Check if the dimensions of the provided image tensors match.""" + reference_dimensions = tensors[0].shape[1:] # Ignore batch dimension + mismatched_images = [names[i] for i, tensor in enumerate(tensors) if tensor.shape[1:] != reference_dimensions] + + if mismatched_images: + raise ValueError(f"Input image dimensions do not match for images: {mismatched_images}") + + @staticmethod + def image_batch(**kwargs): + """Concatenate and batch multiple image tensors.""" + batched_tensors = [tensor for key, tensor in kwargs.items() if tensor is not None] + image_names = [key for key, tensor in kwargs.items() if tensor is not None] + + if not batched_tensors: + raise ValueError("At least one input image must be provided.") + + Utility._check_image_dimensions(batched_tensors, image_names) + batched_tensors = torch.cat(batched_tensors, dim=0) + return batched_tensors + + @staticmethod + def get_model_type(model_patcher): + """Retrieve the type of the model from the model patcher.""" + return model_patcher.model.__class__.__name__ + + @staticmethod + def get_model_type_simple(model_patcher): + """Retrieve a standardized model type from the model patcher.""" + model_type = Utility.get_model_type(model_patcher) + + if model_type == "BaseModel": + return "SD15" + + return model_type + + @classmethod + def put_favourite_on_top(cls, setting, arr): + """Prioritize favorite items in a list based on a setting.""" + # Convert to a list if its a dictionary + if isinstance(arr, dict): + arr = list(arr.keys()) + + favourites = ConfigReader.get_setting(setting, None) + if favourites is None: + return arr + + prioritized = [] + arr_copy = arr[:] + + # Iterate through the copied array + for item in arr_copy: + # Check for full match (case-insensitive) with any favorite + if any(favourite.lower() in item.lower() for favourite in favourites): + prioritized.append(item) + arr.remove(item) + + # Append the remaining items to the prioritized list + prioritized.extend(arr) + return prioritized + + @classmethod + def create_setting_entry(cls, setting_type, setting_value): + """Create a setting entry based on the type and value.""" + if setting_type == "INT": + return ("INT", {"default": setting_value[1], "min": setting_value[2], "max": setting_value[3]}) + if setting_type == "FLOAT": + return ( + "FLOAT", + { + "default": setting_value[1], + "min": setting_value[2], + "max": setting_value[3], + "step": setting_value[4], + }, + ) + if setting_type == "STRING": + return ("STRING", {"default": setting_value[1]}) + if setting_type == "BOOLEAN": + return ("BOOLEAN", {"default": setting_value[1]}) + raise ValueError(f"Unsupported setting type: {setting_type}") + + @classmethod + def get_node_output(cls, data, node_id, output_id): + """Get the output of a node from the workflow data.""" + if not data: + return None + + try: + workflow = data.get("workflow", {}) + nodes = workflow.get("nodes", []) + + for node in nodes: + if int(node.get("id")) == int(node_id): + for output in node.get("outputs", []): + if int(output["slot_index"]) == int(output_id): + return output + return None + except Exception as e: + cls.logger.log(f"Error getting node output: {e}", "ERROR") + return None + + +class AnyType(str): + def __ne__(self, __value: object) -> bool: + return False + + +class Cancelled(Exception): + pass + + +class MessageHolder: + """ + Communicate between the JavaScript frontend and Python backend. + """ + + stash = {} + messages = {} + cancelled = False + routes = PromptServer.instance.routes + API_PREFIX = "/api/sn0w" + logger = Logger() + + @classmethod + def addMessage(cls, id, message): + """Add a message from the API.""" + if message == "__cancel__": + cls.messages = {} + cls.cancelled = True + elif message == "__start__": + cls.messages = {} + cls.stash = {} + cls.cancelled = False + else: + cls.messages[str(id)] = message + + @classmethod + def waitForMessage(cls, id, period=0.1, asList=False): + """Wait for a message from the API.""" + sid = str(id) + while (sid not in cls.messages) and ("-1" not in cls.messages): + if cls.cancelled: + cls.cancelled = False + raise Cancelled() + time.sleep(period) + if cls.cancelled: + cls.cancelled = False + raise Cancelled() + message = cls.messages.pop(str(id), None) or cls.messages.pop("-1") + try: + if asList: + return [int(x.strip()) for x in message.split(",")] + + return int(message.strip()) + except ValueError: + cls.logger.log( + f"failed to parse '{message}' as {'comma separated list of ints' if asList else 'int'}", "ERROR" + ) + return [1] if asList else 1 + + +API_PREFIX = "/sn0w" + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/loras") +async def get_loras(request): + loras = folder_paths.get_filename_list("loras") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras))) + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/embeddings") +async def get_embeddings(request): + embeddings = folder_paths.get_filename_list("embeddings") + return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings))) + + +@PromptServer.instance.routes.get(f"{API_PREFIX}/update_logging_level") +async def update_logging_level(request): + Logger.reload_config() + return web.json_response("Success") + + +@PromptServer.instance.routes.post(f"{API_PREFIX}/add_lora_loaders") +async def add_lora_loaders(request): + dir_path = os.path.dirname(os.path.realpath(__file__)) + if os.path.basename(dir_path) == "src": + dir_path = os.path.dirname(dir_path) + json_path = os.path.join(dir_path, "web/settings/sn0w_settings.json") + # Ensure the directory exists + os.makedirs(os.path.dirname(json_path), exist_ok=True) + try: + # Parse the request body to get the new loaders + new_loaders = await request.json() + if not isinstance(new_loaders, list): + return web.json_response({"error": "Invalid input, expected a list."}, status=400) + + # Read the existing JSON data from the file + if os.path.exists(json_path): + with open(json_path, "r") as file: + data = json.load(file) + else: + data = {} + + # Initialize 'loraLoaders' if it doesn't exist in the JSON + if "loraLoaders" not in data: + data["loraLoaders"] = [] + + # Add only new loaders that are not already in the list + existing_loaders_set = set(tuple(loader) for loader in data["loraLoaders"]) + new_unique_loaders = [loader for loader in new_loaders if tuple(loader) not in existing_loaders_set] + data["loraLoaders"].extend(new_unique_loaders) + + if len(new_unique_loaders) == 0: + return web.json_response({"message": "No new lora loaders added."}) + + # Write the updated JSON data back to the file + with open(json_path, "w") as file: + json.dump(data, file, indent=4) + + new_unique_loaders_str = "[" + ", ".join(str(loader) for loader in new_unique_loaders) + "]" + return web.json_response({"message": f"Loaders added: {new_unique_loaders_str}"}) + + except json.JSONDecodeError: + return web.json_response({"error": "Invalid JSON format."}, status=400) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) diff --git a/web/images/context-menu-images-here b/web/images/context-menu-images-here new file mode 100644 index 0000000..e69de29 diff --git a/web/js/favorite.js b/web/js/favorite.js new file mode 100644 index 0000000..781c7d4 --- /dev/null +++ b/web/js/favorite.js @@ -0,0 +1,391 @@ +import { SettingsHelper } from "./settings/ComfyHelper.js"; +import { hexToRgb, leadingEdgeDebounce } from "./util.js"; +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +const menuCache = new Map(); +const settingsHelper = new SettingsHelper("SyntaxHighlighter"); + +async function toggleFavourite( + existingList, + filename, + setting = "SyntaxHighlighter.favorites" +) { + try { + // Check if the filename is already in the list + const index = existingList.indexOf(filename); + if (index === -1) { + // Add the new filename to the list + existingList.push(filename); + } else { + // Remove the filename from the list + existingList.splice(index, 1); + } + + menuCache.clear(); + // Store the updated list back in the settings + await api.storeSetting(setting, existingList); + } catch (error) { + console.error("Error updating settings:", error); + } +} + +app.registerExtension({ + name: "SyntaxHighlighter.ToggleFavorite", + async setup() { + let existingList = await settingsHelper.getSettingById( + "SyntaxHighlighter.favorites" + ); + if (!Array.isArray(existingList)) { + existingList = []; + } + + const original_getNodeMenuOptions = app.canvas.getNodeMenuOptions; + app.canvas.getNodeMenuOptions = function (node) { + const options = original_getNodeMenuOptions.apply(this, arguments); + const nullIndex = options.indexOf(null); + + // Collect menu items first + const menuItems = []; + + node.widgets.forEach((widget) => { + if (widget.type === "combo") { + const value = widget.value; + const isFavourite = existingList.includes(value); + + const pathArray = value.split("\\"); + const filename = pathArray[pathArray.length - 1]; + const displayValue = filename.split(".")[0]; + + menuItems.push({ + content: isFavourite + ? `Unfavourite ${displayValue} ☆` + : `Favourite ${displayValue} ★`, + disabled: false, + callback: () => { + toggleFavourite( + existingList, + filename, + "SyntaxHighlighter.favorites" + ); + }, + originalValue: value, + }); + } + }); + + // Sort menu items based on their position in existingList + menuItems.sort((a, b) => { + const aIndex = existingList.indexOf(a.originalValue); + const bIndex = existingList.indexOf(b.originalValue); + if (aIndex === -1 && bIndex === -1) return 0; + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + }); + + // Add sorted items to options + if (nullIndex !== -1) { + options.splice(nullIndex, 0, ...menuItems); + } else { + options.push(...menuItems); + } + + return options; + }; + + observeContextMenu(existingList); + }, +}); + +function brightenColor(color, brightenAmount = 15) { + // Handle hex color + if (color.startsWith("#")) { + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + return `rgb(${Math.min(255, r + brightenAmount)}, ${Math.min( + 255, + g + brightenAmount + )}, ${Math.min(255, b + brightenAmount)})`; + } + + // Handle rgb/rgba color + const rgbValues = color.match(/\d+/g); + if (!rgbValues) return color; + + const brighterRgb = rgbValues + .slice(0, 3) + .map((val) => Math.min(255, parseInt(val) + brightenAmount)); + + return `rgb(${brighterRgb[0]}, ${brighterRgb[1]}, ${brighterRgb[2]})`; +} + +async function addStarsToFavourited(menuEntries, existingList, previewImages) { + const hoverDelay = settingsHelper.getSetting("Preview Image Delay"); + + const root = document.documentElement; + const comfyMenuBgColor = hexToRgb( + getComputedStyle(root).getPropertyValue("--comfy-menu-bg").trim() + ); + + // Function to check and update the background color + const checkAndUpdateBackgroundColor = (entry, currentBgColor) => { + if (currentBgColor == comfyMenuBgColor) { + entry.classList.add("litemenu-entry-custom"); + } + }; + + const observerConfig = { attributes: true, attributeFilter: ["style"] }; + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "style" + ) { + const entry = mutation.target; + const currentBgColor = + window.getComputedStyle(entry).backgroundColor; + checkAndUpdateBackgroundColor(entry, currentBgColor); + } + }); + }); + + // Get parent container once + const menuContainer = menuEntries[0]?.parentElement; + if (!menuContainer) return; + + const noneEntry = + menuEntries[0]?.getAttribute("data-value").toLowerCase() === "none" + ? menuEntries[0] + : null; + const entriesToSort = noneEntry + ? Array.from(menuEntries).slice(1) + : Array.from(menuEntries); + + // Sort entries into starred and unstarred + const starredEntries = []; + const unstarredEntries = []; + + let hoverTimer; + entriesToSort.forEach((entry) => { + const value = entry.getAttribute("data-value"); + let filename = value; + if (value !== null && value.includes("\\")) { + const pathArray = value.split("\\"); + filename = pathArray[pathArray.length - 1]; + } + + entry.addEventListener("mouseover", () => { + removeAllPreviewImages(); + + hoverTimer = setTimeout(() => { + const imageFileName = filename + .split(".")[0] + .replaceAll(/\(.*?\)/g, "") + .trim() + .replaceAll(" ", "_") + .toLowerCase(); + + const imageData = previewImages.find( + (img) => + img.filename + .toLowerCase() + .trim() + .replaceAll(" ", "_") === imageFileName + ); + + if (imageData) { + addPreviewImage(entry, imageData.path); + } + }, hoverDelay); + }); + + entry.addEventListener("click", () => { + clearTimeout(hoverTimer); + removeAllPreviewImages(); + }); + + entry.addEventListener("mouseout", () => { + clearTimeout(hoverTimer); + removeAllPreviewImages(); + }); + + if (existingList.includes(filename)) { + // Create star element + const star = document.createElement("span"); + star.innerHTML = "★"; + star.style.marginLeft = "auto"; + star.style.alignSelf = "center"; + + entry.style.display = "flex"; + entry.style.alignItems = "center"; + + if (!entry.querySelector("span")) { + entry.appendChild(star); + } + + const currentBgColor = + window.getComputedStyle(entry).backgroundColor; + observer.observe(entry, observerConfig); + checkAndUpdateBackgroundColor(entry, currentBgColor); + + starredEntries.push(entry); + } else { + unstarredEntries.push(entry); + } + }); + + // Remove all entries + menuEntries.forEach((entry) => entry.remove()); + + // Add entries in order: none (if exists), starred, unstarred + if (noneEntry) { + menuContainer.appendChild(noneEntry); + } + starredEntries.forEach((entry) => menuContainer.appendChild(entry)); + unstarredEntries.forEach((entry) => menuContainer.appendChild(entry)); +} + +async function observeContextMenu(existingList) { + const favoriteColor = await settingsHelper.getSetting( + "Combo Highlight Color" + ); + const brighterFavoriteColor = brightenColor(favoriteColor); + + const style = document.createElement("style"); + style.textContent = ` + .preview-image { + position: absolute; + z-index: 2000; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + .preview-image.fade-in { + opacity: 1; + } + .preview-image.fade-out { + opacity: 0; + } + + .litegraph.litecontextmenu .litemenu-entry.submenu, .litegraph.litecontextmenu.dark .litemenu-entry.litemenu-entry-custom { + background-color: ${favoriteColor} !important; + display: flex; + align-items: center; + } + .litemenu-entry:hover:not(.disabled):not(.separator).litemenu-entry-custom:hover { + background-color: ${brighterFavoriteColor} !important; + } + `; + document.head.appendChild(style); + let images; + try { + images = await fetch( + "/extensions/ComfyUI-Syntax-Highlight/images/images.json" + ).then((response) => response.json()); + } catch (error) { + console.error("Error fetching images.json:", error); + images = { images: [] }; + } + + const handleMutations = leadingEdgeDebounce(function (mutations) { + let isPreviewImage = false; + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if ( + node.classList && + node.classList.contains("preview-image") + ) { + isPreviewImage = true; + return; + } + }); + mutation.removedNodes.forEach((node) => { + if ( + node.classList && + node.classList.contains("preview-image") + ) { + isPreviewImage = true; + return; + } + }); + }); + + if (isPreviewImage) { + return; + } + + const litecontextmenu = + document.getElementsByClassName("litecontextmenu")[0]; + if (litecontextmenu) { + const menuEntries = + litecontextmenu.querySelectorAll(".litemenu-entry"); + addStarsToFavourited(menuEntries, existingList, images.images); + } + }, 100); + + const observer = new MutationObserver(handleMutations); + + observer.observe(document, { + attributes: false, + childList: true, + characterData: false, + subtree: true, + }); +} + +function addPreviewImage(entry, path, maxHeight = 300, maxWidth = 300) { + const preview = document.createElement("img"); + preview.className = "preview-image"; + preview.src = path; + preview.style.maxWidth = `${maxWidth}px`; + preview.style.maxHeight = `${maxHeight}px`; + preview.style.position = "fixed"; + + const rect = entry.getBoundingClientRect(); + const padding = 20; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (rect.left >= maxWidth + padding) { + preview.style.right = `${viewportWidth - rect.left + padding}px`; + } else { + preview.style.left = `${rect.right + padding}px`; + } + + const estimatedHeight = Math.min( + maxHeight, + preview.naturalHeight || maxHeight + ); + let topPosition = rect.top; + + if (rect.top + estimatedHeight > viewportHeight - padding) { + topPosition = viewportHeight - estimatedHeight - padding; + } + + preview.style.top = `${topPosition}px`; + document.body.appendChild(preview); + + setTimeout(() => { + preview.classList.add("fade-in"); + }, 100); +} + +function removeAllPreviewImages() { + const previewImages = document.querySelectorAll(".preview-image"); + previewImages.forEach((img) => { + img.classList.add("fade-out"); + img.classList.remove("fade-in"); + img.addEventListener( + "transitionend", + () => { + img.remove(); + }, + { once: true } + ); + }); +} diff --git a/web/js/settings.js b/web/js/settings.js new file mode 100644 index 0000000..804d560 --- /dev/null +++ b/web/js/settings.js @@ -0,0 +1,40 @@ +import { SettingsHelper } from "./settings/ComfyHelper.js"; + +export const API_PREFIX = "/SyntaxHighlighter"; +export const settingsHelper = new SettingsHelper("SyntaxHighlighter"); +const settingsDefinitions = [ + { + name: "Textbox Colors", + category: ["SyntaxHighlighter", "Textbox", "TextboxColors"], + defaultValue: "#559c22\n#229c57\n#229c8b\n#226f9c\n#22479c", + tooltip: "A list of either rgb or hex colors, one color per line.", + type: SettingsHelper.ST.MULTILINE, + onChange: () => SettingsHelper.PresetOnChange.reloadSettings(), + }, + { + name: "Textbox Highlight Type", + category: ["SyntaxHighlighter", "Textbox", "TextboxHighlightType"], + defaultValue: "strength", + tooltip: + "If strength, only the first and last colors will be used. If nesting, all colors will be used.", + type: SettingsHelper.ST.COMBO( + { text: "Strength", value: "strength" }, + { text: "Nesting", value: "nesting" } + ), + onChange: () => SettingsHelper.PresetOnChange.reloadSettings(), + }, + { + name: "Combo Highlight Color", + category: ["SyntaxHighlighter", "Combo", "ComboHighlightColor"], + defaultValue: "#008000", + type: SettingsHelper.ST.COLORPICKER, + onChange: () => SettingsHelper.PresetOnChange.reloadSettings(), + }, + { + name: "Preview Image Delay", + category: ["SyntaxHighlighter", "General", "PreviewImageDelay"], + defaultValue: 200, + type: SettingsHelper.ST.SLIDER(0, 1000, 1), + }, +]; +settingsHelper.addSettings(settingsDefinitions); diff --git a/web/js/settings/ComfyHelper.js b/web/js/settings/ComfyHelper.js new file mode 100644 index 0000000..f2c221e --- /dev/null +++ b/web/js/settings/ComfyHelper.js @@ -0,0 +1,933 @@ +// 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.} settingsDefinitions - An array of setting objects. + * @param {Object} settingsDefinitions[].name - The unique name of the setting. + * @param {Array.} 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} 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 + ); + } + } +} + +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, + }, + }; + } +} diff --git a/web/js/settings/LICENSE b/web/js/settings/LICENSE new file mode 100644 index 0000000..e32ee26 --- /dev/null +++ b/web/js/settings/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Lucas Sköld + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/js/textHighlight.js b/web/js/textHighlight.js new file mode 100644 index 0000000..33aa68b --- /dev/null +++ b/web/js/textHighlight.js @@ -0,0 +1,550 @@ +import { settingsHelper, API_PREFIX } from "./settings.js"; +import { hexToRgb } from "./util.js"; + +// Track all enhanced textareas +const enhancedTextareas = new WeakSet(); + +// Create observer for new textareas +const textareaObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === 1) { + // Check the added node itself + if (node.tagName === "TEXTAREA") { + enhanceTextarea(node); + } + // Check children of added node + node.querySelectorAll("textarea").forEach((textarea) => { + enhanceTextarea(textarea); + }); + } + }); + }); +}); + +// Start observing the whole document +textareaObserver.observe(document.body, { + childList: true, + subtree: true, +}); + +// Main function to enhance a textarea +function enhanceTextarea(textarea) { + if ( + enhancedTextareas.has(textarea) || + textarea.closest(".settings-container") || + !textarea.classList.contains("comfy-multiline-input") + ) { + return; + } + enhancedTextareas.add(textarea); + + // Create overlay div + const overlayEl = document.createElement("div"); + overlayEl.className = "input-overlay"; + textarea.parentNode.insertBefore(overlayEl, textarea); + + textarea.style.background = "transparent"; + + // Setup the textarea and overlay + setOverlayPosition(textarea, overlayEl); + setOverlayStyle(textarea, overlayEl); + setTextColors(textarea, overlayEl); + setValidFiles(textarea); + + // Add event listeners + textarea.addEventListener("input", () => { + syncText(textarea, overlayEl); + setOverlayStyle(textarea, overlayEl); + }); + + textarea.addEventListener("keydown", (event) => { + if ( + event.ctrlKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + setTimeout(() => { + syncText(textarea, overlayEl); + }, 10); + } + }); + + // Add observer for style changes + const observer = new MutationObserver(() => { + setOverlayPosition(textarea, overlayEl); + }); + + observer.observe(textarea, { + attributes: true, + attributeFilter: ["style"], + childList: true, + subtree: true, + characterData: true, + }); + + // Clean up overlay if textarea is removed + const parentObserver = new MutationObserver(() => { + if (!document.contains(textarea)) { + overlayEl.remove(); + } + }); + + parentObserver.observe(textarea.parentNode, { + childList: true, + }); +} + +// Initialize highlighting on existing textareas +document.querySelectorAll("textarea").forEach((textarea) => { + enhanceTextarea(textarea); +}); + +async function setTextHighlightType(inputEl) { + const highlightGradient = await settingsHelper.getSetting( + "Textbox Highlight Type" + ); + + let shouldHighlightGradient = false; + switch (highlightGradient) { + case "nesting": + shouldHighlightGradient = false; + break; + case "strength": + shouldHighlightGradient = true; + break; + default: + shouldHighlightGradient = false; + break; + } + + inputEl.highlightGradient = shouldHighlightGradient; +} + +async function setTextColors(inputEl, overlayEl) { + const customTextboxColors = await settingsHelper.getSetting( + "Textbox Colors" + ); + setTextHighlightType(inputEl); + + const colors = customTextboxColors.split("\n"); + inputEl.colors = colors.map((color) => + color.charAt(0) === "#" ? hexToRgb(color) : color + ); + inputEl.errorColor = "var(--error-text)"; + + syncText(inputEl, overlayEl); + return colors; +} + +function escapeHtml(char) { + switch (char) { + case "<": + return "<"; + case ">": + return ">"; + default: + return char; + } +} + +function interpolateColor(color1, color2, factor) { + const rgb1 = color1.match(/\d+/g).map(Number); + const rgb2 = color2.match(/\d+/g).map(Number); + + const r = Math.round(rgb1[0] + factor * (rgb2[0] - rgb1[0])); + const g = Math.round(rgb1[1] + factor * (rgb2[1] - rgb1[1])); + const b = Math.round(rgb1[2] + factor * (rgb2[2] - rgb1[2])); + + return `rgb(${r}, ${g}, ${b})`; +} + +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +function charToType(char) { + switch (char) { + case "<": + return "-lora"; + case "e": + return "-embedding"; + default: + return ""; + } +} + +function extractLoraName(text, currentIndex) { + const loraPrefix = "lora:"; + const startIndex = + text.lastIndexOf(loraPrefix, currentIndex) + loraPrefix.length; + + // Find the end of the LoRA name (next space, comma, or ".safetensors") + let endIndex = text.indexOf(" ", startIndex); + const commaIndex = text.indexOf(",", startIndex); + const safetensorsIndex = text.indexOf(".safetensors", startIndex); + + if (endIndex === -1 || (commaIndex !== -1 && commaIndex < endIndex)) { + endIndex = commaIndex; + } + if ( + endIndex === -1 || + (safetensorsIndex !== -1 && safetensorsIndex < endIndex) + ) { + endIndex = safetensorsIndex; + } + + // If there's no space, comma, or ".safetensors" after the LoRA name, consider the end of the text + const loraName = + endIndex === -1 + ? text.slice(startIndex) + : text.slice(startIndex, endIndex); + return loraName; +} + +function validateName(validFiles, name) { + if (!validFiles) { + console.error("Valid names not defined or not an array."); + return false; + } + + if (validFiles.includes(name)) { + return true; + } else { + return false; + } +} + +function processTags(tags) { + let returnArray = []; + tags.forEach((tag) => { + if (tag.trim()) { + returnArray.push(processTag(tag)); + } + }); + return returnArray; +} + +function processTag(tag) { + let trimmedTag = tag.trim(); + + // Remove HTML tags + trimmedTag = trimmedTag.replace(/<|>/g, ""); + + // Remove the first character if it is a parenthesis + if (trimmedTag.startsWith("(")) { + trimmedTag = trimmedTag.substring(1).trim(); + } + + // Remove everything after a colon + const colonIndex = trimmedTag.indexOf(":"); + if (colonIndex !== -1) { + trimmedTag = trimmedTag.substring(0, colonIndex).trim(); + } + + return trimmedTag; +} + +const charPairs = { + "(": ")", + "<": ">", +}; + +async function syncText(inputEl, overlayEl, tries = 1) { + const text = inputEl.value; + overlayEl.textContent = text; + + const colors = inputEl.colors; + const errorColor = inputEl.errorColor; + const shouldHighlightGradient = inputEl.highlightGradient; + const loraColor = colors ? colors[0] : undefined; + + if ( + !colors || + !errorColor || + !loraColor || + shouldHighlightGradient == undefined + ) { + if (tries < 5) { + setTimeout(() => syncText(inputEl, overlayEl, tries++), tries * 5); + } + return; + } + + let uniqueIdCounter = 0; + const generateUniqueId = (type = "") => `span-${uniqueIdCounter++}${type}`; + + let nestingLevel = 0; + let highlightedText = ""; + let lastIndex = 0; + let spanStack = []; + const uniqueIdMap = new Map(); + + const extractTags = (text) => { + let tags = text.split(",").map((tag) => tag.trim()); + tags = processTags(tags); + const tagCounts = new Map(); + + tags.forEach((tag) => { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + }); + + return { tags, tagCounts }; + }; + + const { tags, tagCounts } = extractTags(text); + + /* + * This loop iterates over each character in the `text` string to apply syntax highlighting and handle special cases. + * - lastIndex: index of the last processed character, used to slice text segments. + * - spanStack: stack to manage opened spans, storing their ids, start positions, original colors, and characters. + */ + for (let i = 0; i < text.length; i++) { + const char = text[i].toLowerCase(); + + // Handle escape characters + if ( + char === "\\" && + i + 1 < text.length && + (text[i + 1] === "(" || text[i + 1] === ")") + ) { + i++; + continue; + } + + // Handle wrong escape characters + if ( + char === "/" && + i + 1 < text.length && + (text[i + 1] === "(" || text[i + 1] === ")") + ) { + highlightedText += + text.slice(lastIndex, i) + + `/`; + console.error(`Replace "${char}" at char ${i} with "\\"`); + lastIndex = i + 1; + continue; + } + + let color = colors[nestingLevel % colors.length]; + let uniqueId = generateUniqueId(charToType(char)); + if (inputEl.highlightGradient === true) { + color = `id-${uniqueId}`; + } + switch (char) { + case "(": + case "<": + highlightedText += + text.slice(lastIndex, i) + + `${escapeHtml( + char + )}`; + spanStack.push({ + id: uniqueId, + start: highlightedText.length, + originalSpan: ``, + nestingLevel, + originalColor: color, + originalChar: char, + }); + nestingLevel++; + lastIndex = i + 1; + break; + case "e": + let embeddingColor = colors[0]; + const embeddingPrefix = "embedding:"; + + // Check if the text starts with "embedding:" at position i + if (text.toLowerCase().startsWith(embeddingPrefix, i)) { + // Find the end of the embedding (next space or comma) + let endIndex = i + embeddingPrefix.length; + while ( + endIndex < text.length && + text[endIndex] !== " " && + text[endIndex] !== "," + ) { + endIndex++; + } + + // Get the full embedding text + const embeddingText = text.slice(i, endIndex); + if ( + validateName( + inputEl.validEmbeddings, + embeddingText.split(":")[1] + ) === false + ) { + embeddingColor = errorColor; + } + const wrappedEmbedding = `${escapeHtml( + embeddingText + )}`; + highlightedText += + text.slice(lastIndex, i) + wrappedEmbedding; + lastIndex = i + embeddingText.length; + } + break; + case ")": + case ">": + if (nestingLevel > 0) { + const { id, originalColor, originalChar } = + spanStack[spanStack.length - 1]; + if (charPairs[originalChar] === char) { + spanStack.pop(); + highlightedText += + text.slice(lastIndex, i) + + `${escapeHtml(char)}`; + nestingLevel--; + + // Extract and validate the LoRA name + if (id.endsWith("lora")) { + const loraName = extractLoraName(text, i); + if ( + validateName(inputEl.validLoras, loraName) === + false + ) { + uniqueIdMap.set(id, [ + errorColor, + originalColor, + ]); + lastIndex = i + 1; + continue; + } + } + + if ( + inputEl.highlightGradient === true || + id.endsWith("lora") + ) { + // Check for the strength + const strengthText = text.slice( + Math.max(0, i - 10), + i + ); + const match = + strengthText.match(/(\d+(\.\d+)?)\s*$/); + if (match) { + const strength = parseFloat(match[1]); + const clampedStrength = Math.max( + 0, + Math.min(2, strength) + ); + const normalizedStrength = clampedStrength / 2; + const newColor = interpolateColor( + colors[0], + colors[colors.length - 1], + easeInOutCubic(normalizedStrength) + ); + uniqueIdMap.set(id, [newColor, originalColor]); + } else { + uniqueIdMap.set(id, [ + interpolateColor( + colors[0], + colors[colors.length - 1], + 0.5 + ), + originalColor, + ]); + } + } + lastIndex = i + 1; + } + } + break; + } + } + + highlightedText += text.slice(lastIndex); + + if (nestingLevel > 0) { + // Apply red highlight to the unclosed spans + while (spanStack.length > 0) { + const spanData = spanStack.pop(); + if (spanData) { + const { id, start, originalSpan } = spanData; + const errorSpanTag = ``; + + if (originalSpan) { + highlightedText = highlightedText.replace( + originalSpan, + errorSpanTag + ); + highlightedText += ``; + } + } + } + } + + // Apply the updated colors to the highlighted text + uniqueIdMap.forEach((colors, id) => { + const [newColor, originalColor] = [colors[0], colors[1]]; + highlightedText = highlightedText.replace( + `id="${id}" style="background-color: ${originalColor};"`, + `id="${id}" style="background-color: ${newColor};"` + ); + }); + + // Highlight duplicate tags + highlightedText + .split(/(,)/) + .map((segment) => { + if (segment === ",") { + return segment; + } + + const trimmedSegment = processTag(segment); + + // Check for duplicates + if (tagCounts.get(trimmedSegment) > 1) { + highlightedText = highlightedText.replaceAll( + trimmedSegment, + `${trimmedSegment}` + ); + } + }) + .join(""); + + overlayEl.innerHTML = highlightedText; +} + +function setOverlayPosition(inputEl, overlayEl) { + const textareaStyle = window.getComputedStyle(inputEl); + overlayEl.style.left = textareaStyle.left; + overlayEl.style.top = textareaStyle.top; + overlayEl.style.width = textareaStyle.width; + overlayEl.style.height = textareaStyle.height; + overlayEl.style.display = textareaStyle.display; + overlayEl.style.transform = textareaStyle.transform; + overlayEl.style.transformOrigin = textareaStyle.transformOrigin; +} + +function setOverlayStyle(inputEl, overlayEl) { + const textareaStyle = window.getComputedStyle(inputEl); + overlayEl.style.backgroundColor = "var(--comfy-input-bg)"; + overlayEl.style.position = "absolute"; + overlayEl.style.fontFamily = textareaStyle.fontFamily; + overlayEl.style.fontSize = textareaStyle.fontSize; + overlayEl.style.fontWeight = textareaStyle.fontWeight; + overlayEl.style.lineHeight = textareaStyle.lineHeight; + overlayEl.style.letterSpacing = textareaStyle.letterSpacing; + overlayEl.style.whiteSpace = textareaStyle.whiteSpace; + overlayEl.style.color = "rgba(0,0,0,0)"; + overlayEl.style.padding = textareaStyle.padding; + overlayEl.style.boxSizing = textareaStyle.boxSizing; + overlayEl.style.zIndex = "1"; + overlayEl.style.pointerEvents = "none"; + overlayEl.style.color = "transparent"; + overlayEl.style.overflow = "hidden"; + overlayEl.style.whiteSpace = "pre-wrap"; + overlayEl.style.wordWrap = "break-word"; +} + +async function setValidFiles(inputEl) { + inputEl.validLoras = await getValidFiles("loras"); + inputEl.validEmbeddings = await getValidFiles("embeddings"); +} + +async function getValidFiles(type) { + return await settingsHelper.fetchApi(`${API_PREFIX}/${type}`, { + method: "GET", + }); +} diff --git a/web/js/util.js b/web/js/util.js new file mode 100644 index 0000000..49546d6 --- /dev/null +++ b/web/js/util.js @@ -0,0 +1,34 @@ +export function hexToRgb(hex) { + // Remove the hash at the start if it's there + hex = hex.replace(/^#/, ""); + + // Parse the r, g, b values + let bigint = parseInt(hex, 16); + let r = (bigint >> 16) & 255; + let g = (bigint >> 8) & 255; + let b = bigint & 255; + + return `rgb(${r}, ${g}, ${b})`; +} + +export function 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); + }; +}