Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
+11
@@ -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/*/*
|
||||
+75
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.<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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) +
|
||||
`<span style="background-color: ${errorColor};">/</span>`;
|
||||
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) +
|
||||
`<span id="${uniqueId}" style="background-color: ${color};">${escapeHtml(
|
||||
char
|
||||
)}`;
|
||||
spanStack.push({
|
||||
id: uniqueId,
|
||||
start: highlightedText.length,
|
||||
originalSpan: `<span id="${uniqueId}" style="background-color: ${color};">`,
|
||||
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 = `<span id="${uniqueId}" style="background-color: ${embeddingColor};">${escapeHtml(
|
||||
embeddingText
|
||||
)}</span>`;
|
||||
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)}</span>`;
|
||||
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 = `<span id="${id}" style="background-color: ${errorColor};">`;
|
||||
|
||||
if (originalSpan) {
|
||||
highlightedText = highlightedText.replace(
|
||||
originalSpan,
|
||||
errorSpanTag
|
||||
);
|
||||
highlightedText += `</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
`<span style="background-color: ${errorColor};">${trimmedSegment}</span>`
|
||||
);
|
||||
}
|
||||
})
|
||||
.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",
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user