Initial commit

This commit is contained in:
Lucas Sköld
2025-01-24 18:40:18 +01:00
commit 1b339cb762
11 changed files with 2591 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+11
View File
@@ -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
View File
@@ -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})
+534
View File
@@ -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)
View File
+391
View File
@@ -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 }
);
});
}
+40
View File
@@ -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);
+933
View File
@@ -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,
},
};
}
}
+21
View File
@@ -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.
+550
View File
@@ -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 "&lt;";
case ">":
return "&gt;";
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",
});
}
+34
View File
@@ -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);
};
}