mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b1abb719 | ||
|
|
3e2cfb552b | ||
|
|
779be1b8d0 | ||
|
|
faf74de238 | ||
|
|
50a51c2e79 | ||
|
|
d31e641496 | ||
|
|
f2d36f5be9 | ||
|
|
0b55f61fac | ||
|
|
4156dcbafd | ||
|
|
36e6ac2362 | ||
|
|
9613199152 | ||
|
|
14328d7496 | ||
|
|
6af12d1acc | ||
|
|
9b44e49879 | ||
|
|
afee18f146 |
@@ -20,6 +20,11 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.4
|
||||
* **Node Layout Improvements** - Fixed layout issues with LoRA Loader and Trigger Words Toggle nodes in newer ComfyUI frontend versions
|
||||
* **Recipe LoRA Reconnection** - Added ability to reconnect deleted LoRAs in recipes by clicking the "deleted" badge in recipe details
|
||||
* **Bug Fixes & Stability** - Resolved various issues for improved reliability
|
||||
|
||||
### v0.8.3
|
||||
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
|
||||
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
|
||||
|
||||
11
py/config.py
11
py/config.py
@@ -85,6 +85,17 @@ class Config:
|
||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return path
|
||||
|
||||
def map_link_to_path(self, link_path: str) -> str:
|
||||
"""将符号链接路径映射回实际路径"""
|
||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||
# 检查路径是否包含在任何映射的目标路径中
|
||||
for target_path, link_path in self._path_mappings.items():
|
||||
if normalized_link.startswith(target_path):
|
||||
# 如果路径以目标路径开头,则替换为实际路径
|
||||
mapped_path = normalized_link.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return link_path
|
||||
|
||||
def _init_lora_paths(self) -> List[str]:
|
||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||
|
||||
@@ -34,6 +34,7 @@ class SaveImage:
|
||||
"file_format": (["png", "jpeg", "webp"],),
|
||||
},
|
||||
"optional": {
|
||||
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
|
||||
"lossless_webp": ("BOOLEAN", {"default": True}),
|
||||
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
|
||||
"embed_workflow": ("BOOLEAN", {"default": False}),
|
||||
@@ -60,7 +61,7 @@ class SaveImage:
|
||||
return item.get('sha256')
|
||||
return None
|
||||
|
||||
async def format_metadata(self, parsed_workflow):
|
||||
async def format_metadata(self, parsed_workflow, custom_prompt=None):
|
||||
"""Format metadata in the requested format similar to userComment example"""
|
||||
if not parsed_workflow:
|
||||
return ""
|
||||
@@ -69,6 +70,10 @@ class SaveImage:
|
||||
prompt = parsed_workflow.get('prompt', '')
|
||||
negative_prompt = parsed_workflow.get('negative_prompt', '')
|
||||
|
||||
# Override prompt with custom_prompt if provided
|
||||
if custom_prompt:
|
||||
prompt = custom_prompt
|
||||
|
||||
# Extract loras from the prompt if present
|
||||
loras_text = parsed_workflow.get('loras', '')
|
||||
lora_hashes = {}
|
||||
@@ -240,7 +245,8 @@ class SaveImage:
|
||||
return filename
|
||||
|
||||
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||
custom_prompt=None):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
|
||||
@@ -252,43 +258,45 @@ class SaveImage:
|
||||
parsed_workflow = {}
|
||||
|
||||
# Get or create metadata asynchronously
|
||||
metadata = asyncio.run(self.format_metadata(parsed_workflow))
|
||||
metadata = asyncio.run(self.format_metadata(parsed_workflow, custom_prompt))
|
||||
|
||||
# Process filename_prefix with pattern substitution
|
||||
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
|
||||
|
||||
# Process each image
|
||||
# Get initial save path info once for the batch
|
||||
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
if not os.path.exists(full_output_folder):
|
||||
os.makedirs(full_output_folder, exist_ok=True)
|
||||
|
||||
# Process each image with incrementing counter
|
||||
for i, image in enumerate(images):
|
||||
# Convert the tensor image to numpy array
|
||||
img = 255. * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Create directory if filename_prefix contains path separators
|
||||
output_path = os.path.join(self.output_dir, filename_prefix)
|
||||
if not os.path.exists(os.path.dirname(output_path)):
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
# Use folder_paths.get_save_image_path for better counter handling
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, img.width, img.height
|
||||
)
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
if add_counter_to_filename:
|
||||
filename += f"_{counter:05}"
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
base_filename += f"_{current_counter:05}"
|
||||
|
||||
# Set file extension and prepare saving parameters
|
||||
if file_format == "png":
|
||||
file = filename + ".png"
|
||||
file = base_filename + ".png"
|
||||
file_extension = ".png"
|
||||
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
|
||||
pnginfo = PngImagePlugin.PngInfo()
|
||||
elif file_format == "jpeg":
|
||||
file = filename + ".jpg"
|
||||
file = base_filename + ".jpg"
|
||||
file_extension = ".jpg"
|
||||
save_kwargs = {"quality": quality, "optimize": True}
|
||||
elif file_format == "webp":
|
||||
file = filename + ".webp"
|
||||
file = base_filename + ".webp"
|
||||
file_extension = ".webp"
|
||||
save_kwargs = {"quality": quality, "lossless": lossless_webp}
|
||||
|
||||
@@ -338,13 +346,17 @@ class SaveImage:
|
||||
return results
|
||||
|
||||
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||
custom_prompt=""):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
# Convert single image to list for consistent processing
|
||||
images = [images[0]] if len(images.shape) == 3 else [img for img in images]
|
||||
# Ensure images is always a list of images
|
||||
if len(images.shape) == 3: # Single image (height, width, channels)
|
||||
images = [images]
|
||||
else: # Multiple images (batch, height, width, channels)
|
||||
images = [img for img in images]
|
||||
|
||||
# Save all images
|
||||
results = self.save_images(
|
||||
@@ -356,7 +368,8 @@ class SaveImage:
|
||||
lossless_webp,
|
||||
quality,
|
||||
embed_workflow,
|
||||
add_counter_to_filename
|
||||
add_counter_to_filename,
|
||||
custom_prompt if custom_prompt.strip() else None
|
||||
)
|
||||
|
||||
return (images,)
|
||||
@@ -667,12 +667,28 @@ class ApiRoutes:
|
||||
"""Handle model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
target_path = data.get('target_path')
|
||||
file_path = data.get('file_path') # full path of the model file, e.g. /path/to/model.safetensors
|
||||
target_path = data.get('target_path') # folder path to move the model to, e.g. /path/to/target_folder
|
||||
|
||||
if not file_path or not target_path:
|
||||
return web.Response(text='File path and target path are required', status=400)
|
||||
|
||||
# Check if source and destination are the same
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Target file already exists: {target_file_path}"
|
||||
}, status=409) # 409 Conflict
|
||||
|
||||
# Call scanner to handle the move operation
|
||||
success = await self.scanner.move_model(file_path, target_path)
|
||||
|
||||
@@ -821,33 +837,55 @@ class ApiRoutes:
|
||||
"""Handle bulk model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get('file_paths', [])
|
||||
target_path = data.get('target_path')
|
||||
file_paths = data.get('file_paths', []) # list of full paths of the model files, e.g. ["/path/to/model1.safetensors", "/path/to/model2.safetensors"]
|
||||
target_path = data.get('target_path') # folder path to move the models to, e.g. "/path/to/target_folder"
|
||||
|
||||
if not file_paths or not target_path:
|
||||
return web.Response(text='File paths and target path are required', status=400)
|
||||
|
||||
results = []
|
||||
for file_path in file_paths:
|
||||
# Check if source and destination are the same
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": True,
|
||||
"message": "Source and target directories are the same"
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": False,
|
||||
"message": f"Target file already exists: {target_file_path}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Try to move the model
|
||||
success = await self.scanner.move_model(file_path, target_path)
|
||||
results.append({"path": file_path, "success": success})
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": success,
|
||||
"message": "Success" if success else "Failed to move model"
|
||||
})
|
||||
|
||||
# Count successes
|
||||
# Count successes and failures
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
failure_count = len(results) - success_count
|
||||
|
||||
if success_count == len(file_paths):
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Successfully moved {success_count} models'
|
||||
})
|
||||
elif success_count > 0:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||
'results': results
|
||||
})
|
||||
else:
|
||||
return web.Response(text='Failed to move any models', status=500)
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||
'results': results,
|
||||
'success_count': success_count,
|
||||
'failure_count': failure_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||
@@ -962,7 +1000,7 @@ class ApiRoutes:
|
||||
'base_models': base_models
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving base models: {e}", exc_info=True)
|
||||
logger.error(f"Error retrieving base models: {e}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
|
||||
@@ -89,7 +89,7 @@ class LoraRoutes:
|
||||
settings=settings, # Pass settings to template
|
||||
request=request # Pass the request object to the template
|
||||
)
|
||||
logger.info(f"Loras page loaded successfully with {len(cache.raw_data)} items")
|
||||
logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items")
|
||||
except Exception as cache_error:
|
||||
logger.error(f"Error loading cache data: {cache_error}")
|
||||
# 如果获取缓存失败,也显示初始化页面
|
||||
|
||||
@@ -53,6 +53,9 @@ class RecipeRoutes:
|
||||
# Add new endpoint for updating recipe metadata (name and tags)
|
||||
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
|
||||
|
||||
# Add new endpoint for reconnecting deleted LoRAs
|
||||
app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora)
|
||||
|
||||
# Start cache initialization
|
||||
app.on_startup.append(routes._init_cache)
|
||||
|
||||
@@ -762,7 +765,7 @@ class RecipeRoutes:
|
||||
return web.json_response({"error": "Invalid workflow JSON"}, status=400)
|
||||
|
||||
if not workflow_json:
|
||||
return web.json_response({"error": "Missing required workflow_json field"}, status=400)
|
||||
return web.json_response({"error": "Missing workflow JSON"}, status=400)
|
||||
|
||||
# Find the latest image in the temp directory
|
||||
temp_dir = config.temp_directory
|
||||
@@ -1021,3 +1024,113 @@ class RecipeRoutes:
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||
"""Reconnect a deleted LoRA in a recipe to a local LoRA file"""
|
||||
try:
|
||||
# Parse request data
|
||||
data = await request.json()
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['recipe_id', 'lora_data', 'target_name']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return web.json_response({
|
||||
"error": f"Missing required field: {field}"
|
||||
}, status=400)
|
||||
|
||||
recipe_id = data['recipe_id']
|
||||
lora_data = data['lora_data']
|
||||
target_name = data['target_name']
|
||||
|
||||
# Get recipe scanner
|
||||
scanner = self.recipe_scanner
|
||||
lora_scanner = scanner._lora_scanner
|
||||
|
||||
# Check if recipe exists
|
||||
recipe_path = os.path.join(scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_path):
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
# Find target LoRA by name
|
||||
target_lora = await lora_scanner.get_lora_info_by_name(target_name)
|
||||
if not target_lora:
|
||||
return web.json_response({"error": f"Local LoRA not found with name: {target_name}"}, status=404)
|
||||
|
||||
# Load recipe data
|
||||
with open(recipe_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Find the deleted LoRA in the recipe
|
||||
found = False
|
||||
updated_lora = None
|
||||
|
||||
# Identification can be by hash, modelVersionId, or modelName
|
||||
for i, lora in enumerate(recipe_data.get('loras', [])):
|
||||
match_found = False
|
||||
|
||||
# Try to match by available identifiers
|
||||
if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']:
|
||||
match_found = True
|
||||
elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']:
|
||||
match_found = True
|
||||
elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']:
|
||||
match_found = True
|
||||
|
||||
if match_found:
|
||||
# Update LoRA data
|
||||
lora['isDeleted'] = False
|
||||
lora['file_name'] = target_name
|
||||
|
||||
# Update with information from the target LoRA
|
||||
if 'sha256' in target_lora:
|
||||
lora['hash'] = target_lora['sha256'].lower()
|
||||
if target_lora.get("civitai"):
|
||||
lora['modelName'] = target_lora['civitai']['model']['name']
|
||||
lora['modelVersionName'] = target_lora['civitai']['name']
|
||||
lora['modelVersionId'] = target_lora['civitai']['id']
|
||||
|
||||
# Keep original fields for identification
|
||||
|
||||
# Mark as found and store updated lora
|
||||
found = True
|
||||
updated_lora = dict(lora) # Make a copy for response
|
||||
break
|
||||
|
||||
if not found:
|
||||
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
|
||||
|
||||
# Save updated recipe
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
updated_lora['inLibrary'] = True
|
||||
updated_lora['preview_url'] = target_lora['preview_url']
|
||||
updated_lora['localPath'] = target_lora['file_path']
|
||||
|
||||
# Update in cache if it exists
|
||||
if scanner._cache is not None:
|
||||
for cache_item in scanner._cache.raw_data:
|
||||
if cache_item.get('id') == recipe_id:
|
||||
# Replace loras array with updated version
|
||||
cache_item['loras'] = recipe_data['loras']
|
||||
|
||||
# Resort the cache
|
||||
asyncio.create_task(scanner._cache.resort())
|
||||
break
|
||||
|
||||
# Update EXIF metadata if image exists
|
||||
image_path = recipe_data.get('file_path')
|
||||
if image_path and os.path.exists(image_path):
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_lora": updated_lora
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconnecting LoRA: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -75,17 +75,10 @@ class DownloadManager:
|
||||
file_size = file_info.get('sizeKB', 0) * 1024
|
||||
|
||||
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
|
||||
if self.file_monitor and self.file_monitor.handler:
|
||||
# Add both the normalized path and potential alternative paths
|
||||
normalized_path = save_path.replace(os.sep, '/')
|
||||
self.file_monitor.handler.add_ignore_path(normalized_path, file_size)
|
||||
|
||||
# Also add the path with file extension variations (.safetensors)
|
||||
if not normalized_path.endswith('.safetensors'):
|
||||
safetensors_path = os.path.splitext(normalized_path)[0] + '.safetensors'
|
||||
self.file_monitor.handler.add_ignore_path(safetensors_path, file_size)
|
||||
|
||||
logger.debug(f"Added download path to ignore list: {normalized_path} (size: {file_size} bytes)")
|
||||
self.file_monitor.handler.add_ignore_path(
|
||||
save_path.replace(os.sep, '/'),
|
||||
file_size
|
||||
)
|
||||
|
||||
# 5. 准备元数据
|
||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
|
||||
@@ -2,9 +2,10 @@ from operator import itemgetter
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent
|
||||
from typing import List
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from typing import List, Dict, Set
|
||||
from threading import Lock
|
||||
from .lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
@@ -20,91 +21,167 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
self.pending_changes = set() # 待处理的变更
|
||||
self.lock = Lock() # 线程安全锁
|
||||
self.update_task = None # 异步更新任务
|
||||
self._ignore_paths = {} # Change to dictionary to store expiration times
|
||||
self._ignore_paths = set() # Add ignore paths set
|
||||
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
||||
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
||||
|
||||
# Track modified files with timestamps for debouncing
|
||||
self.modified_files: Dict[str, float] = {}
|
||||
self.debounce_timer = None
|
||||
self.debounce_delay = 3.0 # seconds to wait after last modification
|
||||
|
||||
# Track files that are already scheduled for processing
|
||||
self.scheduled_files: Set[str] = set()
|
||||
|
||||
def _should_ignore(self, path: str) -> bool:
|
||||
"""Check if path should be ignored"""
|
||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||
normalized_path = real_path.replace(os.sep, '/')
|
||||
|
||||
# Also check with backslashes for Windows compatibility
|
||||
alt_path = real_path.replace('/', '\\')
|
||||
|
||||
# 使用传入的事件循环而不是尝试获取当前线程的事件循环
|
||||
current_time = self.loop.time()
|
||||
|
||||
# Check if path is in ignore list and not expired
|
||||
if normalized_path in self._ignore_paths and self._ignore_paths[normalized_path] > current_time:
|
||||
return True
|
||||
|
||||
# Also check alternative path format
|
||||
if alt_path in self._ignore_paths and self._ignore_paths[alt_path] > current_time:
|
||||
return True
|
||||
|
||||
return False
|
||||
return real_path.replace(os.sep, '/') in self._ignore_paths
|
||||
|
||||
def add_ignore_path(self, path: str, file_size: int = 0):
|
||||
"""Add path to ignore list with dynamic timeout based on file size"""
|
||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||
normalized_path = real_path.replace(os.sep, '/')
|
||||
self._ignore_paths.add(real_path.replace(os.sep, '/'))
|
||||
|
||||
# Calculate timeout based on file size
|
||||
# For small files, use minimum timeout
|
||||
# For larger files, estimate download time + buffer
|
||||
if file_size > 0:
|
||||
# Estimate download time in seconds (size / speed) + buffer
|
||||
estimated_time = (file_size / self._download_speed) + 10
|
||||
timeout = max(self._min_ignore_timeout, estimated_time)
|
||||
else:
|
||||
timeout = self._min_ignore_timeout
|
||||
|
||||
current_time = self.loop.time()
|
||||
expiration_time = current_time + timeout
|
||||
|
||||
# Store both normalized and alternative path formats
|
||||
self._ignore_paths[normalized_path] = expiration_time
|
||||
|
||||
# Also store with backslashes for Windows compatibility
|
||||
alt_path = real_path.replace('/', '\\')
|
||||
self._ignore_paths[alt_path] = expiration_time
|
||||
|
||||
logger.debug(f"Added ignore path: {normalized_path} (expires in {timeout:.1f}s)")
|
||||
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
|
||||
timeout = 5
|
||||
|
||||
self.loop.call_later(
|
||||
timeout,
|
||||
self._remove_ignore_path,
|
||||
normalized_path
|
||||
self._ignore_paths.discard,
|
||||
real_path.replace(os.sep, '/')
|
||||
)
|
||||
|
||||
def _remove_ignore_path(self, path: str):
|
||||
"""Remove path from ignore list after timeout"""
|
||||
if path in self._ignore_paths:
|
||||
del self._ignore_paths[path]
|
||||
logger.debug(f"Removed ignore path: {path}")
|
||||
|
||||
# Also remove alternative path format
|
||||
alt_path = path.replace('/', '\\')
|
||||
if alt_path in self._ignore_paths:
|
||||
del self._ignore_paths[alt_path]
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||
if event.is_directory:
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
|
||||
# Handle safetensors files directly
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
# We'll process this file directly and ignore subsequent modifications
|
||||
# to prevent duplicate processing
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
if normalized_path not in self.scheduled_files:
|
||||
logger.info(f"LoRA file created: {event.src_path}")
|
||||
self.scheduled_files.add(normalized_path)
|
||||
self._schedule_update('add', event.src_path)
|
||||
|
||||
# Ignore modifications for a short period after creation
|
||||
# This helps avoid duplicate processing
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
normalized_path
|
||||
)
|
||||
|
||||
# For browser downloads, we'll catch them when they're renamed to .safetensors
|
||||
|
||||
def on_modified(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
logger.info(f"LoRA file created: {event.src_path}")
|
||||
self._schedule_update('add', event.src_path)
|
||||
|
||||
# Only process safetensors files
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
|
||||
# Skip if this file is already scheduled for processing
|
||||
if normalized_path in self.scheduled_files:
|
||||
return
|
||||
|
||||
# Update the timestamp for this file
|
||||
self.modified_files[normalized_path] = time.time()
|
||||
|
||||
# Cancel any existing timer
|
||||
if self.debounce_timer:
|
||||
self.debounce_timer.cancel()
|
||||
|
||||
# Set a new timer to process modified files after debounce period
|
||||
self.debounce_timer = self.loop.call_later(
|
||||
self.debounce_delay,
|
||||
self.loop.call_soon_threadsafe,
|
||||
self._process_modified_files
|
||||
)
|
||||
|
||||
def _process_modified_files(self):
|
||||
"""Process files that have been modified after debounce period"""
|
||||
current_time = time.time()
|
||||
files_to_process = []
|
||||
|
||||
# Find files that haven't been modified for debounce_delay seconds
|
||||
for file_path, last_modified in list(self.modified_files.items()):
|
||||
if current_time - last_modified >= self.debounce_delay:
|
||||
# Only process if not already scheduled
|
||||
if file_path not in self.scheduled_files:
|
||||
files_to_process.append(file_path)
|
||||
self.scheduled_files.add(file_path)
|
||||
|
||||
# Auto-remove from scheduled list after reasonable time
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
file_path
|
||||
)
|
||||
|
||||
del self.modified_files[file_path]
|
||||
|
||||
# Process stable files
|
||||
for file_path in files_to_process:
|
||||
logger.info(f"Processing modified LoRA file: {file_path}")
|
||||
self._schedule_update('add', file_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
# Remove from scheduled files if present
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
self.scheduled_files.discard(normalized_path)
|
||||
|
||||
logger.info(f"LoRA file deleted: {event.src_path}")
|
||||
self._schedule_update('remove', event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""Handle file move/rename events"""
|
||||
|
||||
# If destination is a safetensors file, treat it as a new file
|
||||
if event.dest_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.dest_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
|
||||
|
||||
# Only process if not already scheduled
|
||||
if normalized_path not in self.scheduled_files:
|
||||
logger.info(f"LoRA file renamed/moved to: {event.dest_path}")
|
||||
self.scheduled_files.add(normalized_path)
|
||||
self._schedule_update('add', event.dest_path)
|
||||
|
||||
# Auto-remove from scheduled list after reasonable time
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
normalized_path
|
||||
)
|
||||
|
||||
# If source was a safetensors file, treat it as deleted
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
self.scheduled_files.discard(normalized_path)
|
||||
|
||||
logger.info(f"LoRA file moved/renamed from: {event.src_path}")
|
||||
self._schedule_update('remove', event.src_path)
|
||||
|
||||
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
|
||||
"""Schedule a cache update"""
|
||||
with self.lock:
|
||||
@@ -141,6 +218,12 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
for action, file_path in changes:
|
||||
try:
|
||||
if action == 'add':
|
||||
# Check if file already exists in cache
|
||||
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||
if existing:
|
||||
logger.info(f"File {file_path} already in cache, skipping")
|
||||
continue
|
||||
|
||||
# Scan new file
|
||||
lora_data = await self.scanner.scan_single_lora(file_path)
|
||||
if lora_data:
|
||||
|
||||
@@ -75,3 +75,31 @@ class LoraMetadata:
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
self.file_path = file_path.replace(os.sep, '/')
|
||||
|
||||
@dataclass
|
||||
class CheckpointMetadata:
|
||||
"""Represents the metadata structure for a Checkpoint model"""
|
||||
file_name: str # The filename without extension
|
||||
model_name: str # The checkpoint's name defined by the creator
|
||||
file_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Last modified timestamp
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.)
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Optional[Dict] = None # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
|
||||
# Additional checkpoint-specific fields
|
||||
resolution: Optional[str] = None # Native resolution (e.g., 512x512, 1024x1024)
|
||||
vae_included: bool = False # Whether VAE is included in the checkpoint
|
||||
architecture: str = "" # Model architecture (if known)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ def transform_lora_loader(inputs: Dict) -> Dict:
|
||||
"loras": " ".join(lora_texts)
|
||||
}
|
||||
|
||||
if "clip" in inputs:
|
||||
if "clip" in inputs and isinstance(inputs["clip"], dict):
|
||||
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Deleted badge */
|
||||
/* Deleted badge with reconnect functionality */
|
||||
.deleted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -603,6 +603,138 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Add reconnect functionality styles */
|
||||
.deleted-badge.reconnectable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.deleted-badge .reconnect-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
margin-left: -100px;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover .reconnect-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* LoRA reconnect container */
|
||||
.lora-reconnect-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lora-reconnect-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reconnect-instructions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.reconnect-instructions p {
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.reconnect-instructions small {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.reconnect-instructions code {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .reconnect-instructions code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.reconnect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reconnect-input {
|
||||
width: calc(100% - 20px);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reconnect-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn,
|
||||
.reconnect-confirm-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.reconnect-confirm-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.reconnect-confirm-btn:hover {
|
||||
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
||||
}
|
||||
|
||||
/* Recipe status partial state */
|
||||
.recipe-status.partial {
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
|
||||
@@ -31,6 +31,16 @@ class RecipeModal {
|
||||
!event.target.closest('.edit-icon')) {
|
||||
this.saveTagsEdit();
|
||||
}
|
||||
|
||||
// Handle reconnect input
|
||||
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||||
reconnectContainers.forEach(container => {
|
||||
if (container.classList.contains('active') &&
|
||||
!container.contains(event.target) &&
|
||||
!event.target.closest('.deleted-badge.reconnectable')) {
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,8 +368,9 @@ class RecipeModal {
|
||||
</div>`;
|
||||
} else if (isDeleted) {
|
||||
localStatus = `
|
||||
<div class="deleted-badge">
|
||||
<i class="fas fa-trash-alt"></i> Deleted
|
||||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||
</div>`;
|
||||
} else {
|
||||
localStatus = `
|
||||
@@ -387,7 +398,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}">
|
||||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="recipe-lora-thumbnail">
|
||||
${previewMedia}
|
||||
</div>
|
||||
@@ -401,11 +412,29 @@ class RecipeModal {
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
</div>
|
||||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">Cancel</button>
|
||||
<button class="reconnect-confirm-btn">Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners for reconnect functionality
|
||||
setTimeout(() => {
|
||||
this.setupReconnectButtons();
|
||||
}, 100);
|
||||
|
||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||||
this.recipeLorasSyntax = '';
|
||||
|
||||
@@ -829,6 +858,155 @@ class RecipeModal {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// New methods for reconnecting LoRAs
|
||||
setupReconnectButtons() {
|
||||
// Add event listeners to all deleted badges
|
||||
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
|
||||
deletedBadges.forEach(badge => {
|
||||
badge.addEventListener('mouseenter', () => {
|
||||
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
|
||||
});
|
||||
|
||||
badge.addEventListener('mouseleave', () => {
|
||||
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
|
||||
});
|
||||
|
||||
badge.addEventListener('click', (e) => {
|
||||
const loraIndex = badge.getAttribute('data-lora-index');
|
||||
this.showReconnectInput(loraIndex);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to reconnect cancel buttons
|
||||
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
|
||||
cancelButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const container = button.closest('.lora-reconnect-container');
|
||||
this.hideReconnectInput(container);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to reconnect confirm buttons
|
||||
const confirmButtons = document.querySelectorAll('.reconnect-confirm-btn');
|
||||
confirmButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const container = button.closest('.lora-reconnect-container');
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
const loraIndex = container.getAttribute('data-lora-index');
|
||||
this.reconnectLora(loraIndex, input.value);
|
||||
});
|
||||
});
|
||||
|
||||
// Add keydown handlers to reconnect inputs
|
||||
const reconnectInputs = document.querySelectorAll('.reconnect-input');
|
||||
reconnectInputs.forEach(input => {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
const loraIndex = container.getAttribute('data-lora-index');
|
||||
this.reconnectLora(loraIndex, input.value);
|
||||
} else if (e.key === 'Escape') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showReconnectInput(loraIndex) {
|
||||
// Hide any currently active reconnect containers
|
||||
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
|
||||
active.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show the reconnect container for this lora
|
||||
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||||
if (container) {
|
||||
container.classList.add('active');
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
hideReconnectInput(container) {
|
||||
if (container && container.classList.contains('active')) {
|
||||
container.classList.remove('active');
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async reconnectLora(loraIndex, inputValue) {
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
showToast('Please enter a LoRA name or syntax', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse input value to extract file_name
|
||||
let loraSyntaxMatch = inputValue.match(/<lora:([^:>]+)(?::[^>]+)?>/);
|
||||
let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim();
|
||||
|
||||
// Remove any file extension if present
|
||||
fileName = fileName.replace(/\.\w+$/, '');
|
||||
|
||||
// Get the deleted lora data
|
||||
const deletedLora = this.currentRecipe.loras[loraIndex];
|
||||
if (!deletedLora) {
|
||||
showToast('Error: Could not find the LoRA in the recipe', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
|
||||
|
||||
// Call API to reconnect the LoRA
|
||||
const response = await fetch('/api/recipe/lora/reconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: this.recipeId,
|
||||
lora_data: deletedLora,
|
||||
target_name: fileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Hide the reconnect input
|
||||
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||||
this.hideReconnectInput(container);
|
||||
|
||||
// Update the current recipe with the updated lora data
|
||||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||
|
||||
// Show success message
|
||||
showToast('LoRA reconnected successfully', 'success');
|
||||
|
||||
// Refresh modal to show updated content
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
// Refresh recipes list
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
setTimeout(() => {
|
||||
window.recipeManager.loadRecipes(true);
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
showToast(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reconnecting LoRA:', error);
|
||||
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { RecipeModal };
|
||||
@@ -173,11 +173,20 @@ class MoveManager {
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
throw new Error('Failed to move model');
|
||||
}
|
||||
|
||||
showToast('Model moved successfully', 'success');
|
||||
if (result && result.message) {
|
||||
showToast(result.message, 'info');
|
||||
} else {
|
||||
showToast('Model moved successfully', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
@@ -202,11 +211,44 @@ class MoveManager {
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move models');
|
||||
}
|
||||
|
||||
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
|
||||
// Display results with more details
|
||||
if (result.success) {
|
||||
if (result.failure_count > 0) {
|
||||
// Some files failed to move
|
||||
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
|
||||
|
||||
// Log details about failures
|
||||
console.log('Move operation results:', result.results);
|
||||
|
||||
// Get list of failed files with reasons
|
||||
const failedFiles = result.results
|
||||
.filter(r => !r.success)
|
||||
.map(r => {
|
||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||
return `${fileName}: ${r.message}`;
|
||||
});
|
||||
|
||||
// Show first few failures in a toast
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage = failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||
|
||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
// All files moved successfully
|
||||
showToast(`Successfully moved ${result.success_count} models`, 'success');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to move models');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
144
web/comfyui/DomWidget.vue
Normal file
144
web/comfyui/DomWidget.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
class="dom-widget"
|
||||
:title="tooltip"
|
||||
ref="widgetElement"
|
||||
:style="style"
|
||||
v-show="widgetState.visible"
|
||||
>
|
||||
<component
|
||||
v-if="isComponentWidget(widget)"
|
||||
:is="widget.component"
|
||||
:modelValue="widget.value"
|
||||
@update:modelValue="emit('update:widgetValue', $event)"
|
||||
:widget="widget"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useDomClipping } from '@/composables/element/useDomClipping'
|
||||
import {
|
||||
type BaseDOMWidget,
|
||||
isComponentWidget,
|
||||
isDOMWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { widget, widgetState } = defineProps<{
|
||||
widget: BaseDOMWidget<string | object>
|
||||
widgetState: DomWidgetState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:widgetValue', value: string | object): void
|
||||
}>()
|
||||
|
||||
const widgetElement = ref<HTMLElement | undefined>()
|
||||
|
||||
const { style: positionStyle, updatePositionWithTransform } =
|
||||
useAbsolutePosition()
|
||||
const { style: clippingStyle, updateClipPath } = useDomClipping()
|
||||
const style = computed<CSSProperties>(() => ({
|
||||
...positionStyle.value,
|
||||
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||
zIndex: widgetState.zIndex,
|
||||
pointerEvents: widgetState.readonly ? 'none' : 'auto'
|
||||
}))
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const enableDomClipping = computed(() =>
|
||||
settingStore.get('Comfy.DOMClippingEnabled')
|
||||
)
|
||||
|
||||
const updateDomClipping = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas || !widgetElement.value) return
|
||||
|
||||
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
|
||||
if (!selectedNode) return
|
||||
|
||||
const node = widget.node
|
||||
const isSelected = selectedNode === node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
const selectedAreaConfig = renderArea
|
||||
? {
|
||||
x: renderArea[0],
|
||||
y: renderArea[1],
|
||||
width: renderArea[2],
|
||||
height: renderArea[3],
|
||||
scale,
|
||||
offset: [offset[0], offset[1]] as [number, number]
|
||||
}
|
||||
: undefined
|
||||
|
||||
updateClipPath(
|
||||
widgetElement.value,
|
||||
lgCanvas.canvas,
|
||||
isSelected,
|
||||
selectedAreaConfig
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => widgetState,
|
||||
(newState) => {
|
||||
updatePositionWithTransform(newState)
|
||||
if (enableDomClipping.value) {
|
||||
updateDomClipping()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
(newVisible, oldVisible) => {
|
||||
if (!newVisible && oldVisible) {
|
||||
widget.options.onHide?.(widget)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (isDOMWidget(widget)) {
|
||||
if (widget.element.blur) {
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
widget.element.blur()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
|
||||
useEventListener(widget.element, evt, () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
lgCanvas?.selectNode(widget.node)
|
||||
lgCanvas?.bringToFront(widget.node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec = widget.node.constructor.nodeData
|
||||
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||
|
||||
onMounted(() => {
|
||||
if (isDOMWidget(widget) && widgetElement.value) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dom-widget > * {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
</style>
|
||||
@@ -1,193 +1,121 @@
|
||||
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { Size, Vector4 } from '@comfyorg/litegraph'
|
||||
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
|
||||
import type {
|
||||
ICustomWidget,
|
||||
IWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import _ from 'lodash'
|
||||
import { type Component, toRaw } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
import { app } from './app'
|
||||
export interface BaseDOMWidget<V extends object | string>
|
||||
extends ICustomWidget {
|
||||
// ICustomWidget properties
|
||||
type: 'custom'
|
||||
options: DOMWidgetOptions<V>
|
||||
value: V
|
||||
callback?: (value: V) => void
|
||||
|
||||
const SIZE = Symbol()
|
||||
|
||||
interface Rect {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
// BaseDOMWidget properties
|
||||
/** The unique ID of the widget. */
|
||||
readonly id: string
|
||||
/** The node that the widget belongs to. */
|
||||
readonly node: LGraphNode
|
||||
/** Whether the widget is visible. */
|
||||
isVisible(): boolean
|
||||
/** The margin of the widget. */
|
||||
margin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A DOM widget that wraps a custom HTML element as a litegraph widget.
|
||||
*/
|
||||
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||
extends ICustomWidget<T> {
|
||||
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
||||
type: 'custom'
|
||||
name: string
|
||||
extends BaseDOMWidget<V> {
|
||||
element: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
value: V
|
||||
y?: number
|
||||
/**
|
||||
* @deprecated Legacy property used by some extensions for customtext
|
||||
* (textarea) widgets. Use `element` instead as it provides the same
|
||||
* (textarea) widgets. Use {@link element} instead as it provides the same
|
||||
* functionality and works for all DOMWidget types.
|
||||
*/
|
||||
inputEl?: T
|
||||
callback?: (value: V) => void
|
||||
/**
|
||||
* Draw the widget on the canvas.
|
||||
*/
|
||||
draw?: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
widgetHeight: number
|
||||
) => void
|
||||
/**
|
||||
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
|
||||
* on litegraph's IBaseWidget definition, but not called in litegraph.
|
||||
* Currently only called in widgetInputs.ts.
|
||||
*/
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export interface DOMWidgetOptions<
|
||||
T extends HTMLElement,
|
||||
V extends object | string
|
||||
> extends IWidgetOptions {
|
||||
/**
|
||||
* A DOM widget that wraps a Vue component as a litegraph widget.
|
||||
*/
|
||||
export interface ComponentWidget<V extends object | string>
|
||||
extends BaseDOMWidget<V> {
|
||||
readonly component: Component
|
||||
readonly inputSpec: InputSpec
|
||||
}
|
||||
|
||||
export interface DOMWidgetOptions<V extends object | string>
|
||||
extends IWidgetOptions {
|
||||
/**
|
||||
* Whether to render a placeholder rectangle when zoomed out.
|
||||
*/
|
||||
hideOnZoom?: boolean
|
||||
selectOn?: string[]
|
||||
onHide?: (widget: DOMWidget<T, V>) => void
|
||||
onHide?: (widget: BaseDOMWidget<V>) => void
|
||||
getValue?: () => V
|
||||
setValue?: (value: V) => void
|
||||
getMinHeight?: () => number
|
||||
getMaxHeight?: () => number
|
||||
getHeight?: () => string | number
|
||||
onDraw?: (widget: DOMWidget<T, V>) => void
|
||||
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||
onDraw?: (widget: BaseDOMWidget<V>) => void
|
||||
margin?: number
|
||||
/**
|
||||
* @deprecated Use `afterResize` instead. This callback is a legacy API
|
||||
* that fires before resize happens, but it is no longer supported. Now it
|
||||
* fires after resize happens.
|
||||
* The resize logic has been upstreamed to litegraph in
|
||||
* https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
|
||||
*/
|
||||
beforeResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
|
||||
afterResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
|
||||
}
|
||||
|
||||
function intersect(a: Rect, b: Rect): Vector4 | null {
|
||||
const x = Math.max(a.x, b.x)
|
||||
const num1 = Math.min(a.x + a.width, b.x + b.width)
|
||||
const y = Math.max(a.y, b.y)
|
||||
const num2 = Math.min(a.y + a.height, b.y + b.height)
|
||||
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
|
||||
else return null
|
||||
}
|
||||
export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
|
||||
widget: IWidget
|
||||
): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
|
||||
|
||||
function getClipPath(
|
||||
node: LGraphNode,
|
||||
element: HTMLElement,
|
||||
canvasRect: DOMRect
|
||||
): string {
|
||||
const selectedNode: LGraphNode = Object.values(
|
||||
app.canvas.selected_nodes ?? {}
|
||||
)[0] as LGraphNode
|
||||
if (selectedNode && selectedNode !== node) {
|
||||
const elRect = element.getBoundingClientRect()
|
||||
const MARGIN = 4
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const { renderArea } = selectedNode
|
||||
export const isComponentWidget = <V extends object | string>(
|
||||
widget: IWidget
|
||||
): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
|
||||
|
||||
// Get intersection in browser space
|
||||
const intersection = intersect(
|
||||
{
|
||||
x: elRect.left - canvasRect.left,
|
||||
y: elRect.top - canvasRect.top,
|
||||
width: elRect.width,
|
||||
height: elRect.height
|
||||
},
|
||||
{
|
||||
x: (renderArea[0] + offset[0] - MARGIN) * scale,
|
||||
y: (renderArea[1] + offset[1] - MARGIN) * scale,
|
||||
width: (renderArea[2] + 2 * MARGIN) * scale,
|
||||
height: (renderArea[3] + 2 * MARGIN) * scale
|
||||
}
|
||||
)
|
||||
|
||||
if (!intersection) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Convert intersection to canvas scale (element has scale transform)
|
||||
const clipX =
|
||||
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
|
||||
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
|
||||
const clipWidth = intersection[2] / scale + 'px'
|
||||
const clipHeight = intersection[3] / scale + 'px'
|
||||
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
|
||||
return path
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
|
||||
const elementWidgets = new Set<LGraphNode>()
|
||||
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
|
||||
LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||
nodes?: LGraphNode[],
|
||||
out?: LGraphNode[]
|
||||
): LGraphNode[] {
|
||||
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
|
||||
|
||||
for (const node of app.graph.nodes) {
|
||||
if (elementWidgets.has(node)) {
|
||||
const hidden = visibleNodes.indexOf(node) === -1
|
||||
for (const w of node.widgets ?? []) {
|
||||
if (w.element) {
|
||||
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
|
||||
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
|
||||
const isCollapsed = w.element.dataset.collapsed === 'true'
|
||||
const wasHidden = w.element.hidden
|
||||
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
|
||||
w.element.hidden = actualHidden
|
||||
w.element.style.display = actualHidden ? 'none' : ''
|
||||
if (actualHidden && !wasHidden) {
|
||||
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleNodes
|
||||
}
|
||||
|
||||
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
implements DOMWidget<T, V>
|
||||
abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
implements BaseDOMWidget<V>
|
||||
{
|
||||
type: 'custom'
|
||||
name: string
|
||||
element: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
static readonly DEFAULT_MARGIN = 10
|
||||
readonly type: 'custom'
|
||||
readonly name: string
|
||||
readonly options: DOMWidgetOptions<V>
|
||||
computedHeight?: number
|
||||
y: number = 0
|
||||
callback?: (value: V) => void
|
||||
private mouseDownHandler?: (event: MouseEvent) => void
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
type: string,
|
||||
element: T,
|
||||
options: DOMWidgetOptions<T, V> = {}
|
||||
) {
|
||||
readonly id: string
|
||||
readonly node: LGraphNode
|
||||
|
||||
constructor(obj: {
|
||||
id: string
|
||||
node: LGraphNode
|
||||
name: string
|
||||
type: string
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
// @ts-expect-error custom widget type
|
||||
this.type = type
|
||||
this.name = name
|
||||
this.element = element
|
||||
this.options = options
|
||||
this.type = obj.type
|
||||
this.name = obj.name
|
||||
this.options = obj.options
|
||||
|
||||
if (element.blur) {
|
||||
this.mouseDownHandler = (event) => {
|
||||
if (!element.contains(event.target as HTMLElement)) {
|
||||
element.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', this.mouseDownHandler)
|
||||
}
|
||||
this.id = obj.id
|
||||
this.node = obj.node
|
||||
}
|
||||
|
||||
get value(): V {
|
||||
@@ -199,6 +127,67 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
this.callback?.(this.value)
|
||||
}
|
||||
|
||||
get margin(): number {
|
||||
return this.options.margin ?? BaseDOMWidgetImpl.DEFAULT_MARGIN
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return (
|
||||
!_.isNil(this.computedHeight) &&
|
||||
this.computedHeight > 0 &&
|
||||
!['converted-widget', 'hidden'].includes(this.type) &&
|
||||
!this.node.collapsed
|
||||
)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widget_width: number,
|
||||
y: number,
|
||||
widget_height: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
|
||||
// Draw a placeholder rectangle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.rect(
|
||||
this.margin,
|
||||
y + this.margin,
|
||||
widget_width - this.margin * 2,
|
||||
(this.computedHeight ?? widget_height) - 2 * this.margin
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = originalFillStyle
|
||||
}
|
||||
this.options.onDraw?.(this)
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
useDomWidgetStore().unregisterWidget(this.id)
|
||||
}
|
||||
}
|
||||
|
||||
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
extends BaseDOMWidgetImpl<V>
|
||||
implements DOMWidget<T, V>
|
||||
{
|
||||
readonly element: T
|
||||
|
||||
constructor(obj: {
|
||||
id: string
|
||||
node: LGraphNode
|
||||
name: string
|
||||
type: string
|
||||
element: T
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
super(obj)
|
||||
this.element = obj.element
|
||||
}
|
||||
|
||||
/** Extract DOM widget size info */
|
||||
computeLayoutSize(node: LGraphNode) {
|
||||
// @ts-expect-error custom widget type
|
||||
@@ -241,69 +230,61 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
minWidth: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number
|
||||
): void {
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const hidden =
|
||||
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
|
||||
(this.computedHeight ?? 0) <= 0 ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'converted-widget' ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'hidden'
|
||||
export class ComponentWidgetImpl<V extends object | string>
|
||||
extends BaseDOMWidgetImpl<V>
|
||||
implements ComponentWidget<V>
|
||||
{
|
||||
readonly component: Component
|
||||
readonly inputSpec: InputSpec
|
||||
|
||||
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
|
||||
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
|
||||
const isCollapsed = this.element.dataset.collapsed === 'true'
|
||||
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
||||
const wasHidden = this.element.hidden
|
||||
this.element.hidden = actualHidden
|
||||
this.element.style.display = actualHidden ? 'none' : ''
|
||||
|
||||
if (actualHidden && !wasHidden) {
|
||||
this.options.onHide?.(this)
|
||||
}
|
||||
if (actualHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
const elRect = ctx.canvas.getBoundingClientRect()
|
||||
const margin = 10
|
||||
const top = node.pos[0] + offset[0] + margin
|
||||
const left = node.pos[1] + offset[1] + margin + y
|
||||
|
||||
Object.assign(this.element.style, {
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
left: `${top * scale}px`,
|
||||
top: `${left * scale}px`,
|
||||
width: `${widgetWidth - margin * 2}px`,
|
||||
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
|
||||
position: 'absolute',
|
||||
zIndex: app.graph.nodes.indexOf(node),
|
||||
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
||||
constructor(obj: {
|
||||
id: string
|
||||
node: LGraphNode
|
||||
name: string
|
||||
component: Component
|
||||
inputSpec: InputSpec
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
super({
|
||||
...obj,
|
||||
type: 'custom'
|
||||
})
|
||||
|
||||
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
||||
const clipPath = getClipPath(node, this.element, elRect)
|
||||
this.element.style.clipPath = clipPath ?? 'none'
|
||||
this.element.style.willChange = 'clip-path'
|
||||
}
|
||||
|
||||
this.options.onDraw?.(this)
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (this.mouseDownHandler) {
|
||||
document.removeEventListener('mousedown', this.mouseDownHandler)
|
||||
computeLayoutSize() {
|
||||
const minHeight = this.options.getMinHeight?.() ?? 50
|
||||
const maxHeight = this.options.getMaxHeight?.()
|
||||
return {
|
||||
minHeight,
|
||||
maxHeight,
|
||||
minWidth: 0
|
||||
}
|
||||
this.element.remove()
|
||||
}
|
||||
|
||||
serializeValue(): V {
|
||||
return toRaw(this.value)
|
||||
}
|
||||
}
|
||||
|
||||
export const addWidget = <W extends BaseDOMWidget<object | string>>(
|
||||
node: LGraphNode,
|
||||
widget: W
|
||||
) => {
|
||||
node.addCustomWidget(widget)
|
||||
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||
widget.onRemove?.()
|
||||
})
|
||||
|
||||
node.onResize = useChainCallback(node.onResize, () => {
|
||||
widget.options.beforeResize?.call(widget, node)
|
||||
widget.options.afterResize?.call(widget, node)
|
||||
})
|
||||
|
||||
useDomWidgetStore().registerWidget(widget)
|
||||
}
|
||||
|
||||
LGraphNode.prototype.addDOMWidget = function <
|
||||
@@ -314,24 +295,19 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
name: string,
|
||||
type: string,
|
||||
element: T,
|
||||
options: DOMWidgetOptions<T, V> = {}
|
||||
options: DOMWidgetOptions<V> = {}
|
||||
): DOMWidget<T, V> {
|
||||
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
|
||||
const widget = new DOMWidgetImpl({
|
||||
id: generateUUID(),
|
||||
node: this,
|
||||
name,
|
||||
type,
|
||||
element,
|
||||
options: { hideOnZoom: true, ...options }
|
||||
})
|
||||
// Note: Before `LGraphNode.configure` is called, `this.id` is always `-1`.
|
||||
addWidget(this, widget as unknown as BaseDOMWidget<object | string>)
|
||||
|
||||
if (!element.parentElement) {
|
||||
app.canvasContainer.append(element)
|
||||
}
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
|
||||
const { nodeData } = this.constructor
|
||||
const tooltip = (nodeData?.input.required?.[name] ??
|
||||
nodeData?.input.optional?.[name])?.[1]?.tooltip
|
||||
if (tooltip && !element.title) {
|
||||
element.title = tooltip
|
||||
}
|
||||
|
||||
const widget = new DOMWidgetImpl(name, type, element, options)
|
||||
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
||||
// Some custom nodes are explicitly expecting getter and setter of `value`
|
||||
// property to be on instance instead of prototype.
|
||||
@@ -345,55 +321,5 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure selectOn exists before iteration
|
||||
const selectEvents = options.selectOn ?? ['focus', 'click']
|
||||
for (const evt of selectEvents) {
|
||||
element.addEventListener(evt, () => {
|
||||
app.canvas.selectNode(this)
|
||||
app.canvas.bringToFront(this)
|
||||
})
|
||||
}
|
||||
|
||||
this.addCustomWidget(widget)
|
||||
elementWidgets.add(this)
|
||||
|
||||
const collapse = this.collapse
|
||||
this.collapse = function (this: LGraphNode, force?: boolean) {
|
||||
collapse.call(this, force)
|
||||
if (this.collapsed) {
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
}
|
||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const { onConfigure } = this
|
||||
this.onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serializedNode: ISerialisedNode
|
||||
) {
|
||||
onConfigure?.call(this, serializedNode)
|
||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const onRemoved = this.onRemoved
|
||||
this.onRemoved = function (this: LGraphNode) {
|
||||
element.remove()
|
||||
elementWidgets.delete(this)
|
||||
onRemoved?.call(this)
|
||||
}
|
||||
|
||||
// @ts-ignore index with symbol
|
||||
if (!this[SIZE]) {
|
||||
// @ts-ignore index with symbol
|
||||
this[SIZE] = true
|
||||
const onResize = this.onResize
|
||||
this.onResize = function (this: LGraphNode, size: Size) {
|
||||
options.beforeResize?.call(widget, this)
|
||||
onResize?.call(this, size)
|
||||
options.afterResize?.call(widget, this)
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
399
web/comfyui/legacyDomWidget.ts
Normal file
399
web/comfyui/legacyDomWidget.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { Size, Vector4 } from '@comfyorg/litegraph'
|
||||
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
ICustomWidget,
|
||||
IWidgetOptions
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import { app } from './app'
|
||||
|
||||
const SIZE = Symbol()
|
||||
|
||||
interface Rect {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||
extends ICustomWidget<T> {
|
||||
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
||||
type: 'custom'
|
||||
name: string
|
||||
element: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
value: V
|
||||
y?: number
|
||||
/**
|
||||
* @deprecated Legacy property used by some extensions for customtext
|
||||
* (textarea) widgets. Use `element` instead as it provides the same
|
||||
* functionality and works for all DOMWidget types.
|
||||
*/
|
||||
inputEl?: T
|
||||
callback?: (value: V) => void
|
||||
/**
|
||||
* Draw the widget on the canvas.
|
||||
*/
|
||||
draw?: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
widgetHeight: number
|
||||
) => void
|
||||
/**
|
||||
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
|
||||
* on litegraph's IBaseWidget definition, but not called in litegraph.
|
||||
* Currently only called in widgetInputs.ts.
|
||||
*/
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export interface DOMWidgetOptions<
|
||||
T extends HTMLElement,
|
||||
V extends object | string
|
||||
> extends IWidgetOptions {
|
||||
hideOnZoom?: boolean
|
||||
selectOn?: string[]
|
||||
onHide?: (widget: DOMWidget<T, V>) => void
|
||||
getValue?: () => V
|
||||
setValue?: (value: V) => void
|
||||
getMinHeight?: () => number
|
||||
getMaxHeight?: () => number
|
||||
getHeight?: () => string | number
|
||||
onDraw?: (widget: DOMWidget<T, V>) => void
|
||||
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||
}
|
||||
|
||||
function intersect(a: Rect, b: Rect): Vector4 | null {
|
||||
const x = Math.max(a.x, b.x)
|
||||
const num1 = Math.min(a.x + a.width, b.x + b.width)
|
||||
const y = Math.max(a.y, b.y)
|
||||
const num2 = Math.min(a.y + a.height, b.y + b.height)
|
||||
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
|
||||
else return null
|
||||
}
|
||||
|
||||
function getClipPath(
|
||||
node: LGraphNode,
|
||||
element: HTMLElement,
|
||||
canvasRect: DOMRect
|
||||
): string {
|
||||
const selectedNode: LGraphNode = Object.values(
|
||||
app.canvas.selected_nodes ?? {}
|
||||
)[0] as LGraphNode
|
||||
if (selectedNode && selectedNode !== node) {
|
||||
const elRect = element.getBoundingClientRect()
|
||||
const MARGIN = 4
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const { renderArea } = selectedNode
|
||||
|
||||
// Get intersection in browser space
|
||||
const intersection = intersect(
|
||||
{
|
||||
x: elRect.left - canvasRect.left,
|
||||
y: elRect.top - canvasRect.top,
|
||||
width: elRect.width,
|
||||
height: elRect.height
|
||||
},
|
||||
{
|
||||
x: (renderArea[0] + offset[0] - MARGIN) * scale,
|
||||
y: (renderArea[1] + offset[1] - MARGIN) * scale,
|
||||
width: (renderArea[2] + 2 * MARGIN) * scale,
|
||||
height: (renderArea[3] + 2 * MARGIN) * scale
|
||||
}
|
||||
)
|
||||
|
||||
if (!intersection) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Convert intersection to canvas scale (element has scale transform)
|
||||
const clipX =
|
||||
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
|
||||
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
|
||||
const clipWidth = intersection[2] / scale + 'px'
|
||||
const clipHeight = intersection[3] / scale + 'px'
|
||||
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
|
||||
return path
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
|
||||
const elementWidgets = new Set<LGraphNode>()
|
||||
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
|
||||
LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||
nodes?: LGraphNode[],
|
||||
out?: LGraphNode[]
|
||||
): LGraphNode[] {
|
||||
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
|
||||
|
||||
for (const node of app.graph.nodes) {
|
||||
if (elementWidgets.has(node)) {
|
||||
const hidden = visibleNodes.indexOf(node) === -1
|
||||
for (const w of node.widgets ?? []) {
|
||||
if (w.element) {
|
||||
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
|
||||
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
|
||||
const isCollapsed = w.element.dataset.collapsed === 'true'
|
||||
const wasHidden = w.element.hidden
|
||||
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
|
||||
w.element.hidden = actualHidden
|
||||
w.element.style.display = actualHidden ? 'none' : ''
|
||||
if (actualHidden && !wasHidden) {
|
||||
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleNodes
|
||||
}
|
||||
|
||||
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
implements DOMWidget<T, V>
|
||||
{
|
||||
type: 'custom'
|
||||
name: string
|
||||
element: T
|
||||
options: DOMWidgetOptions<T, V>
|
||||
computedHeight?: number
|
||||
callback?: (value: V) => void
|
||||
private mouseDownHandler?: (event: MouseEvent) => void
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
type: string,
|
||||
element: T,
|
||||
options: DOMWidgetOptions<T, V> = {}
|
||||
) {
|
||||
// @ts-expect-error custom widget type
|
||||
this.type = type
|
||||
this.name = name
|
||||
this.element = element
|
||||
this.options = options
|
||||
|
||||
if (element.blur) {
|
||||
this.mouseDownHandler = (event) => {
|
||||
if (!element.contains(event.target as HTMLElement)) {
|
||||
element.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', this.mouseDownHandler)
|
||||
}
|
||||
}
|
||||
|
||||
get value(): V {
|
||||
return this.options.getValue?.() ?? ('' as V)
|
||||
}
|
||||
|
||||
set value(v: V) {
|
||||
this.options.setValue?.(v)
|
||||
this.callback?.(this.value)
|
||||
}
|
||||
|
||||
/** Extract DOM widget size info */
|
||||
computeLayoutSize(node: LGraphNode) {
|
||||
// @ts-expect-error custom widget type
|
||||
if (this.type === 'hidden') {
|
||||
return {
|
||||
minHeight: 0,
|
||||
maxHeight: 0,
|
||||
minWidth: 0
|
||||
}
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(this.element)
|
||||
let minHeight =
|
||||
this.options.getMinHeight?.() ??
|
||||
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
|
||||
let maxHeight =
|
||||
this.options.getMaxHeight?.() ??
|
||||
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
|
||||
|
||||
let prefHeight: string | number =
|
||||
this.options.getHeight?.() ??
|
||||
styles.getPropertyValue('--comfy-widget-height')
|
||||
|
||||
if (typeof prefHeight === 'string' && prefHeight.endsWith?.('%')) {
|
||||
prefHeight =
|
||||
node.size[1] *
|
||||
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
|
||||
} else {
|
||||
prefHeight =
|
||||
typeof prefHeight === 'number' ? prefHeight : parseInt(prefHeight)
|
||||
|
||||
if (isNaN(minHeight)) {
|
||||
minHeight = prefHeight
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
minHeight: isNaN(minHeight) ? 50 : minHeight,
|
||||
maxHeight: isNaN(maxHeight) ? undefined : maxHeight,
|
||||
minWidth: 0
|
||||
}
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number
|
||||
): void {
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const hidden =
|
||||
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
|
||||
(this.computedHeight ?? 0) <= 0 ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'converted-widget' ||
|
||||
// @ts-expect-error custom widget type
|
||||
this.type === 'hidden'
|
||||
|
||||
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
|
||||
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
|
||||
const isCollapsed = this.element.dataset.collapsed === 'true'
|
||||
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
||||
const wasHidden = this.element.hidden
|
||||
this.element.hidden = actualHidden
|
||||
this.element.style.display = actualHidden ? 'none' : ''
|
||||
|
||||
if (actualHidden && !wasHidden) {
|
||||
this.options.onHide?.(this)
|
||||
}
|
||||
if (actualHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
const elRect = ctx.canvas.getBoundingClientRect()
|
||||
const margin = 10
|
||||
const top = node.pos[0] + offset[0] + margin
|
||||
const left = node.pos[1] + offset[1] + margin + y
|
||||
|
||||
Object.assign(this.element.style, {
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
left: `${top * scale}px`,
|
||||
top: `${left * scale}px`,
|
||||
width: `${widgetWidth - margin * 2}px`,
|
||||
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
|
||||
position: 'absolute',
|
||||
zIndex: app.graph.nodes.indexOf(node),
|
||||
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
||||
})
|
||||
|
||||
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
||||
const clipPath = getClipPath(node, this.element, elRect)
|
||||
this.element.style.clipPath = clipPath ?? 'none'
|
||||
this.element.style.willChange = 'clip-path'
|
||||
}
|
||||
|
||||
this.options.onDraw?.(this)
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (this.mouseDownHandler) {
|
||||
document.removeEventListener('mousedown', this.mouseDownHandler)
|
||||
}
|
||||
this.element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
LGraphNode.prototype.addDOMWidget = function <
|
||||
T extends HTMLElement,
|
||||
V extends object | string
|
||||
>(
|
||||
this: LGraphNode,
|
||||
name: string,
|
||||
type: string,
|
||||
element: T,
|
||||
options: DOMWidgetOptions<T, V> = {}
|
||||
): DOMWidget<T, V> {
|
||||
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
|
||||
|
||||
if (!element.parentElement) {
|
||||
app.canvasContainer.append(element)
|
||||
}
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
|
||||
const { nodeData } = this.constructor
|
||||
const tooltip = (nodeData?.input.required?.[name] ??
|
||||
nodeData?.input.optional?.[name])?.[1]?.tooltip
|
||||
if (tooltip && !element.title) {
|
||||
element.title = tooltip
|
||||
}
|
||||
|
||||
const widget = new DOMWidgetImpl(name, type, element, options)
|
||||
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
||||
// Some custom nodes are explicitly expecting getter and setter of `value`
|
||||
// property to be on instance instead of prototype.
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get(this: DOMWidgetImpl<T, V>): V {
|
||||
return this.options.getValue?.() ?? ('' as V)
|
||||
},
|
||||
set(this: DOMWidgetImpl<T, V>, v: V) {
|
||||
this.options.setValue?.(v)
|
||||
this.callback?.(this.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure selectOn exists before iteration
|
||||
const selectEvents = options.selectOn ?? ['focus', 'click']
|
||||
for (const evt of selectEvents) {
|
||||
element.addEventListener(evt, () => {
|
||||
app.canvas.selectNode(this)
|
||||
app.canvas.bringToFront(this)
|
||||
})
|
||||
}
|
||||
|
||||
this.addCustomWidget(widget)
|
||||
elementWidgets.add(this)
|
||||
|
||||
const collapse = this.collapse
|
||||
this.collapse = function (this: LGraphNode, force?: boolean) {
|
||||
collapse.call(this, force)
|
||||
if (this.collapsed) {
|
||||
element.hidden = true
|
||||
element.style.display = 'none'
|
||||
}
|
||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const { onConfigure } = this
|
||||
this.onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serializedNode: ISerialisedNode
|
||||
) {
|
||||
onConfigure?.call(this, serializedNode)
|
||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const onRemoved = this.onRemoved
|
||||
this.onRemoved = function (this: LGraphNode) {
|
||||
element.remove()
|
||||
elementWidgets.delete(this)
|
||||
onRemoved?.call(this)
|
||||
}
|
||||
|
||||
// @ts-ignore index with symbol
|
||||
if (!this[SIZE]) {
|
||||
// @ts-ignore index with symbol
|
||||
this[SIZE] = true
|
||||
const onResize = this.onResize
|
||||
this.onResize = function (this: LGraphNode, size: Size) {
|
||||
options.beforeResize?.call(widget, this)
|
||||
onResize?.call(this, size)
|
||||
options.afterResize?.call(widget, this)
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
882
web/comfyui/legacy_loras_widget.js
Normal file
882
web/comfyui/legacy_loras_widget.js
Normal file
@@ -0,0 +1,882 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
export function addLorasWidget(node, name, opts, callback) {
|
||||
// Create container for loras
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-loras-container";
|
||||
Object.assign(container.style, {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
padding: "6px",
|
||||
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||
borderRadius: "6px",
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
// Initialize default value
|
||||
const defaultValue = opts?.defaultVal || [];
|
||||
|
||||
// Parse LoRA entries from value
|
||||
const parseLoraValue = (value) => {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
// Format LoRA data
|
||||
const formatLoraValue = (loras) => {
|
||||
return loras;
|
||||
};
|
||||
|
||||
// Function to create toggle element
|
||||
const createToggle = (active, onChange) => {
|
||||
const toggle = document.createElement("div");
|
||||
toggle.className = "comfy-lora-toggle";
|
||||
|
||||
updateToggleStyle(toggle, active);
|
||||
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onChange(!active);
|
||||
});
|
||||
|
||||
return toggle;
|
||||
};
|
||||
|
||||
// Helper function to update toggle style
|
||||
function updateToggleStyle(toggleEl, active) {
|
||||
Object.assign(toggleEl.style, {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)",
|
||||
border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`,
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
toggleEl.onmouseenter = () => {
|
||||
toggleEl.style.transform = "scale(1.05)";
|
||||
toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
|
||||
};
|
||||
|
||||
toggleEl.onmouseleave = () => {
|
||||
toggleEl.style.transform = "scale(1)";
|
||||
toggleEl.style.boxShadow = "none";
|
||||
};
|
||||
}
|
||||
|
||||
// Create arrow button for strength adjustment
|
||||
const createArrowButton = (direction, onClick) => {
|
||||
const button = document.createElement("div");
|
||||
button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`;
|
||||
|
||||
Object.assign(button.style, {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
fontSize: "12px",
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
transition: "all 0.2s ease",
|
||||
});
|
||||
|
||||
button.textContent = direction === "left" ? "◀" : "▶";
|
||||
|
||||
button.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
button.onmouseenter = () => {
|
||||
button.style.color = "white";
|
||||
button.style.transform = "scale(1.2)";
|
||||
};
|
||||
|
||||
button.onmouseleave = () => {
|
||||
button.style.color = "rgba(226, 232, 240, 0.8)";
|
||||
button.style.transform = "scale(1)";
|
||||
};
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
// 添加预览弹窗组件
|
||||
class PreviewTooltip {
|
||||
constructor() {
|
||||
this.element = document.createElement('div');
|
||||
Object.assign(this.element.style, {
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
display: 'none',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '300px',
|
||||
});
|
||||
document.body.appendChild(this.element);
|
||||
this.hideTimeout = null; // 添加超时处理变量
|
||||
|
||||
// 添加全局点击事件来隐藏tooltip
|
||||
document.addEventListener('click', () => this.hide());
|
||||
|
||||
// 添加滚动事件监听
|
||||
document.addEventListener('scroll', () => this.hide(), true);
|
||||
}
|
||||
|
||||
async show(loraName, x, y) {
|
||||
try {
|
||||
// 清除之前的隐藏定时器
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = null;
|
||||
}
|
||||
|
||||
// 如果已经显示同一个lora的预览,则不重复显示
|
||||
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentLora = loraName;
|
||||
|
||||
// 获取预览URL
|
||||
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview URL');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.preview_url) {
|
||||
throw new Error('No preview available');
|
||||
}
|
||||
|
||||
// 清除现有内容
|
||||
while (this.element.firstChild) {
|
||||
this.element.removeChild(this.element.firstChild);
|
||||
}
|
||||
|
||||
// Create media container with relative positioning
|
||||
const mediaContainer = document.createElement('div');
|
||||
Object.assign(mediaContainer.style, {
|
||||
position: 'relative',
|
||||
maxWidth: '300px',
|
||||
maxHeight: '300px',
|
||||
});
|
||||
|
||||
const isVideo = data.preview_url.endsWith('.mp4');
|
||||
const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
|
||||
|
||||
Object.assign(mediaElement.style, {
|
||||
maxWidth: '300px',
|
||||
maxHeight: '300px',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
if (isVideo) {
|
||||
mediaElement.autoplay = true;
|
||||
mediaElement.loop = true;
|
||||
mediaElement.muted = true;
|
||||
mediaElement.controls = false;
|
||||
}
|
||||
|
||||
mediaElement.src = data.preview_url;
|
||||
|
||||
// Create name label with absolute positioning
|
||||
const nameLabel = document.createElement('div');
|
||||
nameLabel.textContent = loraName;
|
||||
Object.assign(nameLabel.style, {
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
padding: '8px',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
||||
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'center',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
});
|
||||
|
||||
mediaContainer.appendChild(mediaElement);
|
||||
mediaContainer.appendChild(nameLabel);
|
||||
this.element.appendChild(mediaContainer);
|
||||
|
||||
// 添加淡入效果
|
||||
this.element.style.opacity = '0';
|
||||
this.element.style.display = 'block';
|
||||
this.position(x, y);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.element.style.transition = 'opacity 0.15s ease';
|
||||
this.element.style.opacity = '1';
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to load preview:', error);
|
||||
}
|
||||
}
|
||||
|
||||
position(x, y) {
|
||||
// 确保预览框不超出视窗边界
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = x + 10; // 默认在鼠标右侧偏移10px
|
||||
let top = y + 10; // 默认在鼠标下方偏移10px
|
||||
|
||||
// 检查右边界
|
||||
if (left + rect.width > viewportWidth) {
|
||||
left = x - rect.width - 10;
|
||||
}
|
||||
|
||||
// 检查下边界
|
||||
if (top + rect.height > viewportHeight) {
|
||||
top = y - rect.height - 10;
|
||||
}
|
||||
|
||||
Object.assign(this.element.style, {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`
|
||||
});
|
||||
}
|
||||
|
||||
hide() {
|
||||
// 使用淡出效果
|
||||
if (this.element.style.display === 'block') {
|
||||
this.element.style.opacity = '0';
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.element.style.display = 'none';
|
||||
this.currentLora = null;
|
||||
// 停止视频播放
|
||||
const video = this.element.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
}
|
||||
this.hideTimeout = null;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
}
|
||||
// 移除所有事件监听器
|
||||
document.removeEventListener('click', () => this.hide());
|
||||
document.removeEventListener('scroll', () => this.hide(), true);
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建预览tooltip实例
|
||||
const previewTooltip = new PreviewTooltip();
|
||||
|
||||
// Function to create menu item
|
||||
const createMenuItem = (text, icon, onClick) => {
|
||||
const menuItem = document.createElement('div');
|
||||
Object.assign(menuItem.style, {
|
||||
padding: '6px 20px',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(226, 232, 240, 0.9)',
|
||||
fontSize: '13px',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
// Create icon element
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.innerHTML = icon;
|
||||
Object.assign(iconEl.style, {
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
// Create text element
|
||||
const textEl = document.createElement('span');
|
||||
textEl.textContent = text;
|
||||
|
||||
menuItem.appendChild(iconEl);
|
||||
menuItem.appendChild(textEl);
|
||||
|
||||
menuItem.addEventListener('mouseenter', () => {
|
||||
menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||
});
|
||||
|
||||
menuItem.addEventListener('mouseleave', () => {
|
||||
menuItem.style.backgroundColor = 'transparent';
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
menuItem.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
};
|
||||
|
||||
// Function to create context menu
|
||||
const createContextMenu = (x, y, loraName, widget) => {
|
||||
// Hide preview tooltip first
|
||||
previewTooltip.hide();
|
||||
|
||||
// Remove existing context menu if any
|
||||
const existingMenu = document.querySelector('.comfy-lora-context-menu');
|
||||
if (existingMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'comfy-lora-context-menu';
|
||||
Object.assign(menu.style, {
|
||||
position: 'fixed',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 0',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
||||
minWidth: '180px',
|
||||
});
|
||||
|
||||
// View on Civitai option with globe icon
|
||||
const viewOnCivitaiOption = createMenuItem(
|
||||
'View on Civitai',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>',
|
||||
async () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
try {
|
||||
// Get Civitai URL from API
|
||||
const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to get Civitai URL');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.civitai_url) {
|
||||
// Open the URL in a new tab
|
||||
window.open(data.civitai_url, '_blank');
|
||||
} else {
|
||||
// Show error message if no Civitai URL
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warning',
|
||||
summary: 'Not Found',
|
||||
detail: 'This LoRA has no associated Civitai URL',
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
alert('This LoRA has no associated Civitai URL');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting Civitai URL:', error);
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: error.message || 'Failed to get Civitai URL',
|
||||
life: 5000
|
||||
});
|
||||
} else {
|
||||
alert('Error: ' + (error.message || 'Failed to get Civitai URL'));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete option with trash icon
|
||||
const deleteOption = createMenuItem(
|
||||
'Delete',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18m-2 0v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName);
|
||||
widget.value = formatLoraValue(lorasData);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Save recipe option with bookmark icon
|
||||
const saveOption = createMenuItem(
|
||||
'Save Recipe',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
saveRecipeDirectly(widget);
|
||||
}
|
||||
);
|
||||
|
||||
// Add separator
|
||||
const separator = document.createElement('div');
|
||||
Object.assign(separator.style, {
|
||||
margin: '4px 0',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
});
|
||||
|
||||
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
|
||||
menu.appendChild(deleteOption);
|
||||
menu.appendChild(separator);
|
||||
menu.appendChild(saveOption);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Close menu when clicking outside
|
||||
const closeMenu = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||
};
|
||||
|
||||
// Function to render loras from data
|
||||
const renderLoras = (value, widget) => {
|
||||
// Clear existing content
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
// Parse the loras data
|
||||
const lorasData = parseLoraValue(value);
|
||||
|
||||
if (lorasData.length === 0) {
|
||||
// Show message when no loras are added
|
||||
const emptyMessage = document.createElement("div");
|
||||
emptyMessage.textContent = "No LoRAs added";
|
||||
Object.assign(emptyMessage.style, {
|
||||
textAlign: "center",
|
||||
padding: "20px 0",
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontStyle: "italic",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
});
|
||||
container.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create header
|
||||
const header = document.createElement("div");
|
||||
header.className = "comfy-loras-header";
|
||||
Object.assign(header.style, {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||
marginBottom: "8px"
|
||||
});
|
||||
|
||||
// Add toggle all control
|
||||
const allActive = lorasData.every(lora => lora.active);
|
||||
const toggleAll = createToggle(allActive, (active) => {
|
||||
// Update all loras active state
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
lorasData.forEach(lora => lora.active = active);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
widget.value = newValue;
|
||||
});
|
||||
|
||||
// Add label to toggle all
|
||||
const toggleLabel = document.createElement("div");
|
||||
toggleLabel.textContent = "Toggle All";
|
||||
Object.assign(toggleLabel.style, {
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontSize: "13px",
|
||||
marginLeft: "8px",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
});
|
||||
|
||||
const toggleContainer = document.createElement("div");
|
||||
Object.assign(toggleContainer.style, {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
});
|
||||
toggleContainer.appendChild(toggleAll);
|
||||
toggleContainer.appendChild(toggleLabel);
|
||||
|
||||
// Strength label
|
||||
const strengthLabel = document.createElement("div");
|
||||
strengthLabel.textContent = "Strength";
|
||||
Object.assign(strengthLabel.style, {
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontSize: "13px",
|
||||
marginRight: "8px",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
});
|
||||
|
||||
header.appendChild(toggleContainer);
|
||||
header.appendChild(strengthLabel);
|
||||
container.appendChild(header);
|
||||
|
||||
// Render each lora entry
|
||||
lorasData.forEach((loraData) => {
|
||||
const { name, strength, active } = loraData;
|
||||
|
||||
const loraEl = document.createElement("div");
|
||||
loraEl.className = "comfy-lora-entry";
|
||||
Object.assign(loraEl.style, {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
|
||||
transition: "all 0.2s ease",
|
||||
marginBottom: "6px",
|
||||
});
|
||||
|
||||
// Create toggle for this lora
|
||||
const toggle = createToggle(active, (newActive) => {
|
||||
// Update this lora's active state
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].active = newActive;
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
widget.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Create name display
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.textContent = name;
|
||||
Object.assign(nameEl.style, {
|
||||
marginLeft: "10px",
|
||||
flex: "1",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer", // Add pointer cursor to indicate hoverable area
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
});
|
||||
|
||||
// Move preview tooltip events to nameEl instead of loraEl
|
||||
nameEl.addEventListener('mouseenter', async (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = nameEl.getBoundingClientRect();
|
||||
await previewTooltip.show(name, rect.right, rect.top);
|
||||
});
|
||||
|
||||
nameEl.addEventListener('mouseleave', (e) => {
|
||||
e.stopPropagation();
|
||||
previewTooltip.hide();
|
||||
});
|
||||
|
||||
// Remove the preview tooltip events from loraEl
|
||||
loraEl.onmouseenter = () => {
|
||||
loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)";
|
||||
};
|
||||
|
||||
loraEl.onmouseleave = () => {
|
||||
loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
|
||||
};
|
||||
|
||||
// Add context menu event
|
||||
loraEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
createContextMenu(e.clientX, e.clientY, name, widget);
|
||||
});
|
||||
|
||||
// Create strength control
|
||||
const strengthControl = document.createElement("div");
|
||||
Object.assign(strengthControl.style, {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
});
|
||||
|
||||
// Left arrow
|
||||
const leftArrow = createArrowButton("left", () => {
|
||||
// Decrease strength
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
widget.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Strength display
|
||||
const strengthEl = document.createElement("input");
|
||||
strengthEl.type = "text";
|
||||
strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
|
||||
Object.assign(strengthEl.style, {
|
||||
minWidth: "50px",
|
||||
width: "50px",
|
||||
textAlign: "center",
|
||||
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||
fontSize: "13px",
|
||||
background: "none",
|
||||
border: "1px solid transparent",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "3px",
|
||||
outline: "none",
|
||||
});
|
||||
|
||||
// 添加hover效果
|
||||
strengthEl.addEventListener('mouseenter', () => {
|
||||
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
|
||||
});
|
||||
|
||||
strengthEl.addEventListener('mouseleave', () => {
|
||||
if (document.activeElement !== strengthEl) {
|
||||
strengthEl.style.border = "1px solid transparent";
|
||||
}
|
||||
});
|
||||
|
||||
// 处理焦点
|
||||
strengthEl.addEventListener('focus', () => {
|
||||
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
||||
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
|
||||
// 自动选中所有内容
|
||||
strengthEl.select();
|
||||
});
|
||||
|
||||
strengthEl.addEventListener('blur', () => {
|
||||
strengthEl.style.border = "1px solid transparent";
|
||||
strengthEl.style.background = "none";
|
||||
});
|
||||
|
||||
// 处理输入变化
|
||||
strengthEl.addEventListener('change', () => {
|
||||
let newValue = parseFloat(strengthEl.value);
|
||||
|
||||
// 验证输入
|
||||
if (isNaN(newValue)) {
|
||||
newValue = 1.0;
|
||||
}
|
||||
|
||||
// 更新数值
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = newValue.toFixed(2);
|
||||
|
||||
// 更新值并触发回调
|
||||
const newLorasValue = formatLoraValue(lorasData);
|
||||
widget.value = newLorasValue;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理按键事件
|
||||
strengthEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
strengthEl.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Right arrow
|
||||
const rightArrow = createArrowButton("right", () => {
|
||||
// Increase strength
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
widget.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
strengthControl.appendChild(leftArrow);
|
||||
strengthControl.appendChild(strengthEl);
|
||||
strengthControl.appendChild(rightArrow);
|
||||
|
||||
// Assemble entry
|
||||
const leftSection = document.createElement("div");
|
||||
Object.assign(leftSection.style, {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flex: "1",
|
||||
minWidth: "0", // Allow shrinking
|
||||
});
|
||||
|
||||
leftSection.appendChild(toggle);
|
||||
leftSection.appendChild(nameEl);
|
||||
|
||||
loraEl.appendChild(leftSection);
|
||||
loraEl.appendChild(strengthControl);
|
||||
|
||||
container.appendChild(loraEl);
|
||||
});
|
||||
};
|
||||
|
||||
// Store the value in a variable to avoid recursion
|
||||
let widgetValue = defaultValue;
|
||||
|
||||
// Create widget with initial properties
|
||||
const widget = node.addDOMWidget(name, "loras", container, {
|
||||
getValue: function() {
|
||||
return widgetValue;
|
||||
},
|
||||
setValue: function(v) {
|
||||
// Remove duplicates by keeping the last occurrence of each lora name
|
||||
const uniqueValue = (v || []).reduce((acc, lora) => {
|
||||
// Remove any existing lora with the same name
|
||||
const filtered = acc.filter(l => l.name !== lora.name);
|
||||
// Add the current lora
|
||||
return [...filtered, lora];
|
||||
}, []);
|
||||
|
||||
widgetValue = uniqueValue;
|
||||
renderLoras(widgetValue, widget);
|
||||
|
||||
// Update container height after rendering
|
||||
requestAnimationFrame(() => {
|
||||
const minHeight = this.getMinHeight();
|
||||
container.style.height = `${minHeight}px`;
|
||||
|
||||
// Force node to update size
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
},
|
||||
getMinHeight: function() {
|
||||
// Calculate height based on content
|
||||
const lorasCount = parseLoraValue(widgetValue).length;
|
||||
return Math.max(
|
||||
100,
|
||||
lorasCount > 0 ? 60 + lorasCount * 44 : 60
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
widget.value = defaultValue;
|
||||
|
||||
widget.callback = callback;
|
||||
|
||||
widget.serializeValue = () => {
|
||||
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
||||
return [...widgetValue,
|
||||
{ name: "__dummy_item1__", strength: 0, active: false, _isDummy: true },
|
||||
{ name: "__dummy_item2__", strength: 0, active: false, _isDummy: true }
|
||||
];
|
||||
}
|
||||
|
||||
widget.onRemove = () => {
|
||||
container.remove();
|
||||
previewTooltip.cleanup();
|
||||
};
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget };
|
||||
}
|
||||
|
||||
// Function to directly save the recipe without dialog
|
||||
async function saveRecipeDirectly(widget) {
|
||||
try {
|
||||
// Get the workflow data from the ComfyUI app
|
||||
const prompt = await app.graphToPrompt();
|
||||
console.log('Prompt:', prompt);
|
||||
|
||||
// Show loading toast
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Saving Recipe',
|
||||
detail: 'Please wait...',
|
||||
life: 2000
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare the data - only send workflow JSON
|
||||
const formData = new FormData();
|
||||
formData.append('workflow_json', JSON.stringify(prompt.output));
|
||||
|
||||
// Send the request
|
||||
const response = await fetch('/api/recipes/save-from-widget', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show result toast
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
if (result.success) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Recipe Saved',
|
||||
detail: 'Recipe has been saved successfully',
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: result.error || 'Failed to save recipe',
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving recipe:', error);
|
||||
|
||||
// Show error toast
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'),
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
193
web/comfyui/legacy_tags_widget.js
Normal file
193
web/comfyui/legacy_tags_widget.js
Normal file
@@ -0,0 +1,193 @@
|
||||
export function addTagsWidget(node, name, opts, callback) {
|
||||
// Create container for tags
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-tags-container";
|
||||
Object.assign(container.style, {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px", // 从8px减小到4px
|
||||
padding: "6px",
|
||||
minHeight: "30px",
|
||||
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
|
||||
borderRadius: "6px", // Slightly larger radius
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
// Initialize default value as array
|
||||
const initialTagsData = opts?.defaultVal || [];
|
||||
|
||||
// Function to render tags from array data
|
||||
const renderTags = (tagsData, widget) => {
|
||||
// Clear existing tags
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
const normalizedTags = tagsData;
|
||||
|
||||
if (normalizedTags.length === 0) {
|
||||
// Show message when no tags are present
|
||||
const emptyMessage = document.createElement("div");
|
||||
emptyMessage.textContent = "No trigger words detected";
|
||||
Object.assign(emptyMessage.style, {
|
||||
textAlign: "center",
|
||||
padding: "20px 0",
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontStyle: "italic",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
});
|
||||
container.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedTags.forEach((tagData, index) => {
|
||||
const { text, active } = tagData;
|
||||
const tagEl = document.createElement("div");
|
||||
tagEl.className = "comfy-tag";
|
||||
|
||||
updateTagStyle(tagEl, active);
|
||||
|
||||
tagEl.textContent = text;
|
||||
tagEl.title = text; // Set tooltip for full content
|
||||
|
||||
// Add click handler to toggle state
|
||||
tagEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle active state for this specific tag using its index
|
||||
const updatedTags = [...widget.value];
|
||||
updatedTags[index].active = !updatedTags[index].active;
|
||||
updateTagStyle(tagEl, updatedTags[index].active);
|
||||
|
||||
widget.value = updatedTags;
|
||||
});
|
||||
|
||||
container.appendChild(tagEl);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to update tag style based on active state
|
||||
function updateTagStyle(tagEl, active) {
|
||||
const baseStyles = {
|
||||
padding: "4px 12px", // 垂直内边距从6px减小到4px
|
||||
borderRadius: "6px", // Matching container radius
|
||||
maxWidth: "200px", // Increased max width
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "13px", // Slightly larger font
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease", // Smoother transition
|
||||
border: "1px solid transparent",
|
||||
display: "inline-block",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
||||
margin: "2px", // 从4px减小到2px
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
};
|
||||
|
||||
if (active) {
|
||||
Object.assign(tagEl.style, {
|
||||
...baseStyles,
|
||||
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
|
||||
color: "white",
|
||||
borderColor: "rgba(66, 153, 225, 0.9)",
|
||||
});
|
||||
} else {
|
||||
Object.assign(tagEl.style, {
|
||||
...baseStyles,
|
||||
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
|
||||
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
|
||||
borderColor: "rgba(226, 232, 240, 0.2)",
|
||||
});
|
||||
}
|
||||
|
||||
// Add hover effect
|
||||
tagEl.onmouseenter = () => {
|
||||
tagEl.style.transform = "translateY(-1px)";
|
||||
tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
|
||||
};
|
||||
|
||||
tagEl.onmouseleave = () => {
|
||||
tagEl.style.transform = "translateY(0)";
|
||||
tagEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
|
||||
};
|
||||
}
|
||||
|
||||
// Store the value as array
|
||||
let widgetValue = initialTagsData;
|
||||
|
||||
// Create widget with initial properties
|
||||
const widget = node.addDOMWidget(name, "tags", container, {
|
||||
getValue: function() {
|
||||
return widgetValue;
|
||||
},
|
||||
setValue: function(v) {
|
||||
widgetValue = v;
|
||||
renderTags(widgetValue, widget);
|
||||
|
||||
// Update container height after rendering
|
||||
requestAnimationFrame(() => {
|
||||
const minHeight = this.getMinHeight();
|
||||
container.style.height = `${minHeight}px`;
|
||||
|
||||
// Force node to update size
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
},
|
||||
getMinHeight: function() {
|
||||
const minHeight = 150;
|
||||
// If no tags or only showing the empty message, return a minimum height
|
||||
if (widgetValue.length === 0) {
|
||||
return minHeight; // Height for empty state with message
|
||||
}
|
||||
|
||||
// Get all tag elements
|
||||
const tagElements = container.querySelectorAll('.comfy-tag');
|
||||
|
||||
if (tagElements.length === 0) {
|
||||
return minHeight; // Fallback if elements aren't rendered yet
|
||||
}
|
||||
|
||||
// Calculate the actual height based on tag positions
|
||||
let maxBottom = 0;
|
||||
|
||||
tagElements.forEach(tag => {
|
||||
const rect = tag.getBoundingClientRect();
|
||||
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
|
||||
maxBottom = Math.max(maxBottom, tagBottom);
|
||||
});
|
||||
|
||||
// Add padding (top and bottom padding of container)
|
||||
const computedStyle = window.getComputedStyle(container);
|
||||
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
|
||||
|
||||
// Add extra buffer for potential wrapping issues and to ensure no clipping
|
||||
const extraBuffer = 20;
|
||||
|
||||
// Round up to nearest 5px for clean sizing and ensure minimum height
|
||||
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
|
||||
},
|
||||
});
|
||||
|
||||
widget.value = initialTagsData;
|
||||
|
||||
widget.callback = callback;
|
||||
|
||||
widget.serializeValue = () => {
|
||||
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
||||
return [...widgetValue,
|
||||
{ text: "__dummy_item__", active: false, _isDummy: true },
|
||||
{ text: "__dummy_item__", active: false, _isDummy: true }
|
||||
];
|
||||
};
|
||||
|
||||
return { minWidth: 300, minHeight: 150, widget };
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
import { dynamicImportByVersion } from "./utils.js";
|
||||
|
||||
// Extract pattern into a constant for consistent use
|
||||
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
|
||||
|
||||
// Function to get the appropriate loras widget based on ComfyUI version
|
||||
async function getLorasWidgetModule() {
|
||||
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
|
||||
}
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
let match;
|
||||
@@ -44,7 +49,7 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// Wait for node to be properly initialized
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// Restore saved value if exists
|
||||
let existingLoras = [];
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
@@ -67,6 +72,10 @@ app.registerExtension({
|
||||
|
||||
// Add flag to prevent callback loops
|
||||
let isUpdating = false;
|
||||
|
||||
// Dynamically load the appropriate widget module
|
||||
const lorasModule = await getLorasWidgetModule();
|
||||
const { addLorasWidget } = lorasModule;
|
||||
|
||||
// Get the widget object directly from the returned object
|
||||
const result = addLorasWidget(node, "loras", {
|
||||
|
||||
@@ -5,19 +5,34 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
// Create container for loras
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-loras-container";
|
||||
|
||||
// Set initial height using CSS variables approach
|
||||
const defaultHeight = 200;
|
||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
||||
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
||||
|
||||
Object.assign(container.style, {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
gap: "5px",
|
||||
padding: "6px",
|
||||
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||
borderRadius: "6px",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
overflow: "auto"
|
||||
});
|
||||
|
||||
// Initialize default value
|
||||
const defaultValue = opts?.defaultVal || [];
|
||||
|
||||
// Fixed sizes for component calculations
|
||||
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
||||
const HEADER_HEIGHT = 40; // Height of the header section
|
||||
const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||
const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
|
||||
|
||||
// Parse LoRA entries from value
|
||||
const parseLoraValue = (value) => {
|
||||
if (!value) return [];
|
||||
@@ -29,6 +44,23 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
return loras;
|
||||
};
|
||||
|
||||
// Function to update widget height consistently
|
||||
const updateWidgetHeight = (height) => {
|
||||
// Ensure minimum height
|
||||
const finalHeight = Math.max(defaultHeight, height);
|
||||
|
||||
// Update CSS variables
|
||||
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
|
||||
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
|
||||
|
||||
// Force node to update size after a short delay to ensure DOM is updated
|
||||
if (node) {
|
||||
setTimeout(() => {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to create toggle element
|
||||
const createToggle = (active, onChange) => {
|
||||
const toggle = document.createElement("div");
|
||||
@@ -107,7 +139,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
return button;
|
||||
};
|
||||
|
||||
// 添加预览弹窗组件
|
||||
// Preview tooltip class
|
||||
class PreviewTooltip {
|
||||
constructor() {
|
||||
this.element = document.createElement('div');
|
||||
@@ -122,31 +154,31 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
maxWidth: '300px',
|
||||
});
|
||||
document.body.appendChild(this.element);
|
||||
this.hideTimeout = null; // 添加超时处理变量
|
||||
this.hideTimeout = null;
|
||||
|
||||
// 添加全局点击事件来隐藏tooltip
|
||||
// Add global click event to hide tooltip
|
||||
document.addEventListener('click', () => this.hide());
|
||||
|
||||
// 添加滚动事件监听
|
||||
// Add scroll event listener
|
||||
document.addEventListener('scroll', () => this.hide(), true);
|
||||
}
|
||||
|
||||
async show(loraName, x, y) {
|
||||
try {
|
||||
// 清除之前的隐藏定时器
|
||||
// Clear previous hide timer
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = null;
|
||||
}
|
||||
|
||||
// 如果已经显示同一个lora的预览,则不重复显示
|
||||
// Don't redisplay the same lora preview
|
||||
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentLora = loraName;
|
||||
|
||||
// 获取预览URL
|
||||
// Get preview URL
|
||||
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
@@ -160,7 +192,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
throw new Error('No preview available');
|
||||
}
|
||||
|
||||
// 清除现有内容
|
||||
// Clear existing content
|
||||
while (this.element.firstChild) {
|
||||
this.element.removeChild(this.element.firstChild);
|
||||
}
|
||||
@@ -217,7 +249,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
mediaContainer.appendChild(nameLabel);
|
||||
this.element.appendChild(mediaContainer);
|
||||
|
||||
// 添加淡入效果
|
||||
// Add fade-in effect
|
||||
this.element.style.opacity = '0';
|
||||
this.element.style.display = 'block';
|
||||
this.position(x, y);
|
||||
@@ -232,20 +264,20 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
}
|
||||
|
||||
position(x, y) {
|
||||
// 确保预览框不超出视窗边界
|
||||
// Ensure preview box doesn't exceed viewport boundaries
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = x + 10; // 默认在鼠标右侧偏移10px
|
||||
let top = y + 10; // 默认在鼠标下方偏移10px
|
||||
let left = x + 10; // Default 10px offset to the right of mouse
|
||||
let top = y + 10; // Default 10px offset below mouse
|
||||
|
||||
// 检查右边界
|
||||
// Check right boundary
|
||||
if (left + rect.width > viewportWidth) {
|
||||
left = x - rect.width - 10;
|
||||
}
|
||||
|
||||
// 检查下边界
|
||||
// Check bottom boundary
|
||||
if (top + rect.height > viewportHeight) {
|
||||
top = y - rect.height - 10;
|
||||
}
|
||||
@@ -257,13 +289,13 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
}
|
||||
|
||||
hide() {
|
||||
// 使用淡出效果
|
||||
// Use fade-out effect
|
||||
if (this.element.style.display === 'block') {
|
||||
this.element.style.opacity = '0';
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.element.style.display = 'none';
|
||||
this.currentLora = null;
|
||||
// 停止视频播放
|
||||
// Stop video playback
|
||||
const video = this.element.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
@@ -277,14 +309,14 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
if (this.hideTimeout) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
}
|
||||
// 移除所有事件监听器
|
||||
// Remove all event listeners
|
||||
document.removeEventListener('click', () => this.hide());
|
||||
document.removeEventListener('scroll', () => this.hide(), true);
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建预览tooltip实例
|
||||
// Create preview tooltip instance
|
||||
const previewTooltip = new PreviewTooltip();
|
||||
|
||||
// Function to create menu item
|
||||
@@ -357,7 +389,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
padding: '4px 0',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
||||
minWidth: '180px',
|
||||
minWidth: '180px',
|
||||
});
|
||||
|
||||
// View on Civitai option with globe icon
|
||||
@@ -447,7 +479,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
});
|
||||
|
||||
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
|
||||
menu.appendChild(viewOnCivitaiOption);
|
||||
menu.appendChild(deleteOption);
|
||||
menu.appendChild(separator);
|
||||
menu.appendChild(saveOption);
|
||||
@@ -483,12 +515,16 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
padding: "20px 0",
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontStyle: "italic",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
width: "100%"
|
||||
});
|
||||
container.appendChild(emptyMessage);
|
||||
|
||||
// Set fixed height for empty state
|
||||
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,7 +537,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||
marginBottom: "8px"
|
||||
marginBottom: "5px"
|
||||
});
|
||||
|
||||
// Add toggle all control
|
||||
@@ -522,10 +558,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontSize: "13px",
|
||||
marginLeft: "8px",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
});
|
||||
|
||||
const toggleContainer = document.createElement("div");
|
||||
@@ -543,10 +579,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
fontSize: "13px",
|
||||
marginRight: "8px",
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
});
|
||||
|
||||
header.appendChild(toggleContainer);
|
||||
@@ -563,11 +599,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px",
|
||||
padding: "6px",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
|
||||
transition: "all 0.2s ease",
|
||||
marginBottom: "6px",
|
||||
marginBottom: "4px",
|
||||
});
|
||||
|
||||
// Create toggle for this lora
|
||||
@@ -595,11 +631,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
whiteSpace: "nowrap",
|
||||
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer", // Add pointer cursor to indicate hoverable area
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
});
|
||||
|
||||
// Move preview tooltip events to nameEl instead of loraEl
|
||||
@@ -645,7 +681,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
widget.value = newValue;
|
||||
@@ -669,7 +705,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
outline: "none",
|
||||
});
|
||||
|
||||
// 添加hover效果
|
||||
// Add hover effect
|
||||
strengthEl.addEventListener('mouseenter', () => {
|
||||
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
|
||||
});
|
||||
@@ -680,11 +716,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
}
|
||||
});
|
||||
|
||||
// 处理焦点
|
||||
// Handle focus
|
||||
strengthEl.addEventListener('focus', () => {
|
||||
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
||||
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
|
||||
// 自动选中所有内容
|
||||
// Auto-select all content
|
||||
strengthEl.select();
|
||||
});
|
||||
|
||||
@@ -693,29 +729,29 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
strengthEl.style.background = "none";
|
||||
});
|
||||
|
||||
// 处理输入变化
|
||||
// Handle input changes
|
||||
strengthEl.addEventListener('change', () => {
|
||||
let newValue = parseFloat(strengthEl.value);
|
||||
|
||||
// 验证输入
|
||||
// Validate input
|
||||
if (isNaN(newValue)) {
|
||||
newValue = 1.0;
|
||||
}
|
||||
|
||||
// 更新数值
|
||||
// Update value
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = newValue.toFixed(2);
|
||||
|
||||
// 更新值并触发回调
|
||||
// Update value and trigger callback
|
||||
const newLorasValue = formatLoraValue(lorasData);
|
||||
widget.value = newLorasValue;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理按键事件
|
||||
// Handle key events
|
||||
strengthEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
strengthEl.blur();
|
||||
@@ -757,13 +793,17 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
container.appendChild(loraEl);
|
||||
});
|
||||
|
||||
// Calculate height based on number of loras and fixed sizes
|
||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (lorasData.length * LORA_ENTRY_HEIGHT);
|
||||
updateWidgetHeight(calculatedHeight);
|
||||
};
|
||||
|
||||
// Store the value in a variable to avoid recursion
|
||||
let widgetValue = defaultValue;
|
||||
|
||||
// Create widget with initial properties
|
||||
const widget = node.addDOMWidget(name, "loras", container, {
|
||||
// Create widget with new DOM Widget API
|
||||
const widget = node.addDOMWidget(name, "custom", container, {
|
||||
getValue: function() {
|
||||
return widgetValue;
|
||||
},
|
||||
@@ -778,29 +818,28 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
widgetValue = uniqueValue;
|
||||
renderLoras(widgetValue, widget);
|
||||
|
||||
// Update container height after rendering
|
||||
requestAnimationFrame(() => {
|
||||
const minHeight = this.getMinHeight();
|
||||
container.style.height = `${minHeight}px`;
|
||||
|
||||
// Force node to update size
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
},
|
||||
getMinHeight: function() {
|
||||
// Calculate height based on content
|
||||
const lorasCount = parseLoraValue(widgetValue).length;
|
||||
return Math.max(
|
||||
100,
|
||||
lorasCount > 0 ? 60 + lorasCount * 44 : 60
|
||||
);
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
||||
},
|
||||
getMaxHeight: function() {
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
||||
},
|
||||
getHeight: function() {
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
||||
},
|
||||
hideOnZoom: true,
|
||||
selectOn: ['click', 'focus'],
|
||||
afterResize: function(node) {
|
||||
// Re-render after node resize
|
||||
if (this.value && this.value.length > 0) {
|
||||
renderLoras(this.value, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
widget.value = defaultValue;
|
||||
|
||||
|
||||
widget.callback = callback;
|
||||
|
||||
widget.serializeValue = () => {
|
||||
@@ -816,7 +855,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
previewTooltip.cleanup();
|
||||
};
|
||||
|
||||
return { minWidth: 400, minHeight: 200, widget };
|
||||
return { minWidth: 400, minHeight: defaultHeight, widget };
|
||||
}
|
||||
|
||||
// Function to directly save the recipe without dialog
|
||||
@@ -824,7 +863,6 @@ async function saveRecipeDirectly(widget) {
|
||||
try {
|
||||
// Get the workflow data from the ComfyUI app
|
||||
const prompt = await app.graphToPrompt();
|
||||
console.log('Prompt:', prompt);
|
||||
|
||||
// Show loading toast
|
||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||
@@ -879,4 +917,4 @@ async function saveRecipeDirectly(widget) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,36 @@ export function addTagsWidget(node, name, opts, callback) {
|
||||
// Create container for tags
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-tags-container";
|
||||
|
||||
// Set initial height
|
||||
const defaultHeight = 150;
|
||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
||||
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
||||
|
||||
Object.assign(container.style, {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px", // 从8px减小到4px
|
||||
gap: "4px",
|
||||
padding: "6px",
|
||||
minHeight: "30px",
|
||||
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
|
||||
borderRadius: "6px", // Slightly larger radius
|
||||
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||
borderRadius: "6px",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
overflow: "auto",
|
||||
alignItems: "flex-start" // Ensure tags align at the top of each row
|
||||
});
|
||||
|
||||
// Initialize default value as array
|
||||
const initialTagsData = opts?.defaultVal || [];
|
||||
|
||||
// Fixed sizes for tag elements to avoid zoom-related calculation issues
|
||||
const TAG_HEIGHT = 26; // Adjusted height of a single tag including margins
|
||||
const TAGS_PER_ROW = 3; // Approximate number of tags per row
|
||||
const ROW_GAP = 2; // Reduced gap between rows
|
||||
const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||
const EMPTY_CONTAINER_HEIGHT = 60; // Height when no tags are present
|
||||
|
||||
// Function to render tags from array data
|
||||
const renderTags = (tagsData, widget) => {
|
||||
// Clear existing tags
|
||||
@@ -38,11 +54,28 @@ export function addTagsWidget(node, name, opts, callback) {
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
width: "100%"
|
||||
});
|
||||
container.appendChild(emptyMessage);
|
||||
|
||||
// Set fixed height for empty state
|
||||
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a row container approach for better layout control
|
||||
let rowContainer = document.createElement("div");
|
||||
rowContainer.className = "comfy-tags-row";
|
||||
Object.assign(rowContainer.style, {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px",
|
||||
width: "100%",
|
||||
marginBottom: "2px" // Small gap between rows
|
||||
});
|
||||
container.appendChild(rowContainer);
|
||||
|
||||
let tagCount = 0;
|
||||
normalizedTags.forEach((tagData, index) => {
|
||||
const { text, active } = tagData;
|
||||
const tagEl = document.createElement("div");
|
||||
@@ -65,44 +98,75 @@ export function addTagsWidget(node, name, opts, callback) {
|
||||
widget.value = updatedTags;
|
||||
});
|
||||
|
||||
container.appendChild(tagEl);
|
||||
rowContainer.appendChild(tagEl);
|
||||
tagCount++;
|
||||
});
|
||||
|
||||
// Calculate height based on number of tags and fixed sizes
|
||||
const tagsCount = normalizedTags.length;
|
||||
const rows = Math.ceil(tagsCount / TAGS_PER_ROW);
|
||||
const calculatedHeight = CONTAINER_PADDING + (rows * TAG_HEIGHT) + ((rows - 1) * ROW_GAP);
|
||||
|
||||
// Update widget height with calculated value
|
||||
updateWidgetHeight(calculatedHeight);
|
||||
};
|
||||
|
||||
// Function to update widget height consistently
|
||||
const updateWidgetHeight = (height) => {
|
||||
// Ensure minimum height
|
||||
const finalHeight = Math.max(defaultHeight, height);
|
||||
|
||||
// Update CSS variables
|
||||
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
|
||||
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
|
||||
|
||||
// Force node to update size after a short delay to ensure DOM is updated
|
||||
if (node) {
|
||||
setTimeout(() => {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}, 10);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to update tag style based on active state
|
||||
function updateTagStyle(tagEl, active) {
|
||||
const baseStyles = {
|
||||
padding: "4px 12px", // 垂直内边距从6px减小到4px
|
||||
borderRadius: "6px", // Matching container radius
|
||||
maxWidth: "200px", // Increased max width
|
||||
padding: "4px 10px", // Slightly reduced horizontal padding
|
||||
borderRadius: "6px",
|
||||
maxWidth: "200px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "13px", // Slightly larger font
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease", // Smoother transition
|
||||
transition: "all 0.2s ease",
|
||||
border: "1px solid transparent",
|
||||
display: "inline-block",
|
||||
display: "inline-flex", // Changed to inline-flex for better text alignment
|
||||
alignItems: "center", // Center text vertically
|
||||
justifyContent: "center", // Center text horizontally
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
||||
margin: "2px", // 从4px减小到2px
|
||||
userSelect: "none", // Add this line to prevent text selection
|
||||
WebkitUserSelect: "none", // For Safari support
|
||||
MozUserSelect: "none", // For Firefox support
|
||||
msUserSelect: "none", // For IE/Edge support
|
||||
margin: "1px", // Reduced margin
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
height: "20px", // Slightly increased height to prevent text cutoff
|
||||
minHeight: "20px", // Ensure consistent height
|
||||
boxSizing: "border-box" // Ensure padding doesn't affect the overall size
|
||||
};
|
||||
|
||||
if (active) {
|
||||
Object.assign(tagEl.style, {
|
||||
...baseStyles,
|
||||
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
|
||||
backgroundColor: "rgba(66, 153, 225, 0.9)",
|
||||
color: "white",
|
||||
borderColor: "rgba(66, 153, 225, 0.9)",
|
||||
});
|
||||
} else {
|
||||
Object.assign(tagEl.style, {
|
||||
...baseStyles,
|
||||
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
|
||||
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
|
||||
backgroundColor: "rgba(45, 55, 72, 0.7)",
|
||||
color: "rgba(226, 232, 240, 0.8)",
|
||||
borderColor: "rgba(226, 232, 240, 0.2)",
|
||||
});
|
||||
}
|
||||
@@ -122,72 +186,48 @@ export function addTagsWidget(node, name, opts, callback) {
|
||||
// Store the value as array
|
||||
let widgetValue = initialTagsData;
|
||||
|
||||
// Create widget with initial properties
|
||||
const widget = node.addDOMWidget(name, "tags", container, {
|
||||
// Create widget with new DOM Widget API
|
||||
const widget = node.addDOMWidget(name, "custom", container, {
|
||||
getValue: function() {
|
||||
return widgetValue;
|
||||
},
|
||||
setValue: function(v) {
|
||||
widgetValue = v;
|
||||
renderTags(widgetValue, widget);
|
||||
|
||||
// Update container height after rendering
|
||||
requestAnimationFrame(() => {
|
||||
const minHeight = this.getMinHeight();
|
||||
container.style.height = `${minHeight}px`;
|
||||
|
||||
// Force node to update size
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
},
|
||||
getMinHeight: function() {
|
||||
const minHeight = 150;
|
||||
// If no tags or only showing the empty message, return a minimum height
|
||||
if (widgetValue.length === 0) {
|
||||
return minHeight; // Height for empty state with message
|
||||
}
|
||||
|
||||
// Get all tag elements
|
||||
const tagElements = container.querySelectorAll('.comfy-tag');
|
||||
|
||||
if (tagElements.length === 0) {
|
||||
return minHeight; // Fallback if elements aren't rendered yet
|
||||
}
|
||||
|
||||
// Calculate the actual height based on tag positions
|
||||
let maxBottom = 0;
|
||||
|
||||
tagElements.forEach(tag => {
|
||||
const rect = tag.getBoundingClientRect();
|
||||
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
|
||||
maxBottom = Math.max(maxBottom, tagBottom);
|
||||
});
|
||||
|
||||
// Add padding (top and bottom padding of container)
|
||||
const computedStyle = window.getComputedStyle(container);
|
||||
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
|
||||
|
||||
// Add extra buffer for potential wrapping issues and to ensure no clipping
|
||||
const extraBuffer = 20;
|
||||
|
||||
// Round up to nearest 5px for clean sizing and ensure minimum height
|
||||
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
||||
},
|
||||
getMaxHeight: function() {
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
||||
},
|
||||
getHeight: function() {
|
||||
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
||||
},
|
||||
hideOnZoom: true,
|
||||
selectOn: ['click', 'focus'],
|
||||
afterResize: function(node) {
|
||||
// Re-render tags after node resize
|
||||
if (this.value && this.value.length > 0) {
|
||||
renderTags(this.value, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial value
|
||||
widget.value = initialTagsData;
|
||||
|
||||
// Set callback
|
||||
widget.callback = callback;
|
||||
|
||||
// Add serialization method to avoid ComfyUI serialization issues
|
||||
widget.serializeValue = () => {
|
||||
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
||||
// Add dummy items to avoid the 2-element serialization issue
|
||||
return [...widgetValue,
|
||||
{ text: "__dummy_item__", active: false, _isDummy: true },
|
||||
{ text: "__dummy_item__", active: false, _isDummy: true }
|
||||
];
|
||||
};
|
||||
|
||||
return { minWidth: 300, minHeight: 150, widget };
|
||||
}
|
||||
return { minWidth: 300, minHeight: defaultHeight, widget };
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { addTagsWidget } from "./tags_widget.js";
|
||||
import { CONVERTED_TYPE, dynamicImportByVersion } from "./utils.js";
|
||||
|
||||
const CONVERTED_TYPE = 'converted-widget'
|
||||
// Function to get the appropriate tags widget based on ComfyUI version
|
||||
async function getTagsWidgetModule() {
|
||||
return await dynamicImportByVersion("./tags_widget.js", "./legacy_tags_widget.js");
|
||||
}
|
||||
|
||||
// TriggerWordToggle extension for ComfyUI
|
||||
app.registerExtension({
|
||||
@@ -26,7 +29,11 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// Wait for node to be properly initialized
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// Dynamically import the appropriate tags widget module
|
||||
const tagsModule = await getTagsWidgetModule();
|
||||
const { addTagsWidget } = tagsModule;
|
||||
|
||||
// Get the widget object directly from the returned object
|
||||
const result = addTagsWidget(node, "toggle_trigger_words", {
|
||||
defaultVal: []
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
export const CONVERTED_TYPE = 'converted-widget';
|
||||
|
||||
export function getComfyUIFrontendVersion() {
|
||||
return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
|
||||
}
|
||||
|
||||
// Dynamically import the appropriate widget based on app version
|
||||
export async function dynamicImportByVersion(latestModulePath, legacyModulePath) {
|
||||
// Parse app version and compare with 1.12.6 (version when tags widget API changed)
|
||||
const currentVersion = getComfyUIFrontendVersion();
|
||||
const versionParts = currentVersion.split('.').map(part => parseInt(part, 10));
|
||||
const requiredVersion = [1, 12, 6];
|
||||
|
||||
// Compare version numbers
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (versionParts[i] > requiredVersion[i]) {
|
||||
console.log(`Using latest widget: ${latestModulePath}`);
|
||||
return import(latestModulePath);
|
||||
} else if (versionParts[i] < requiredVersion[i]) {
|
||||
console.log(`Using legacy widget: ${legacyModulePath}`);
|
||||
return import(legacyModulePath);
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, versions are equal, use the latest module
|
||||
console.log(`Using latest widget: ${latestModulePath}`);
|
||||
return import(latestModulePath);
|
||||
}
|
||||
|
||||
export function hideWidgetForGood(node, widget, suffix = "") {
|
||||
widget.origType = widget.type;
|
||||
widget.origComputeSize = widget.computeSize;
|
||||
|
||||
Reference in New Issue
Block a user