Compare commits

...

15 Commits

Author SHA1 Message Date
Will Miao
59b1abb719 Update version to 0.8.4 and add release notes for node layout improvements and bug fixes 2025-04-07 14:49:34 +08:00
Will Miao
3e2cfb552b Refactor image saving logic for batch processing and unique filename generation. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/79 2025-04-07 14:37:39 +08:00
Will Miao
779be1b8d0 Refactor loras_widget styles for improved layout consistency 2025-04-07 13:42:31 +08:00
Will Miao
faf74de238 Enhance model move functionality with detailed error handling and user feedback 2025-04-07 11:14:56 +08:00
Will Miao
50a51c2e79 Refactor Lora widget and dynamic module loading
- Updated lora_loader.js to dynamically import the appropriate loras widget based on ComfyUI version, enhancing compatibility and maintainability.
- Enhanced loras_widget.js with improved height management and styling for better user experience.
- Introduced utility functions in utils.js for version checking and dynamic imports, streamlining widget loading processes.
- Improved overall structure and readability of the code, ensuring better performance and easier future updates.
2025-04-07 09:02:36 +08:00
Will Miao
d31e641496 Add dynamic tags widget selection based on ComfyUI version
- Introduced a mechanism to dynamically import either the legacy or modern tags widget based on the ComfyUI frontend version.
- Updated the `addTagsWidget` function in both `tags_widget.js` and `legacy_tags_widget.js` to enhance tag rendering and widget height management.
- Improved styling and layout for tags, ensuring better alignment and responsiveness.
- Added a new serialization method to handle potential issues with ComfyUI's serialization process.
- Enhanced the overall user experience by providing a more modern and flexible tags widget implementation.
2025-04-07 08:42:20 +08:00
Will Miao
f2d36f5be9 Refactor DownloadManager and LoraFileHandler for improved file monitoring
- Simplified the path handling in DownloadManager by directly adding normalized paths to the ignore list.
- Updated LoraFileHandler to utilize a set for ignore paths, enhancing performance and clarity.
- Implemented debouncing for modified file events to prevent duplicate processing and improve efficiency.
- Enhanced the handling of file creation, modification, and deletion events for .safetensors files, ensuring accurate processing and logging.
- Adjusted cache operations to streamline the addition and removal of files based on real paths.
2025-04-06 22:27:55 +08:00
Will Miao
0b55f61fac Refactor LoraFileHandler to use real file paths for monitoring
- Updated the file monitoring logic to store and verify real file paths instead of mapped paths, ensuring accurate existence checks.
- Enhanced logging for error handling and processing actions, including detailed error messages with exception info.
- Adjusted cache operations to reflect the use of normalized paths for consistency in add/remove actions.
- Improved handling of ignore paths by removing successfully processed files from the ignore list.
2025-04-05 12:10:46 +08:00
pixelpaws
4156dcbafd Merge pull request #83 from willmiao/dev
Dev
2025-04-05 05:28:22 +08:00
Will Miao
36e6ac2362 Add CheckpointMetadata class for enhanced model metadata management
- Introduced a new CheckpointMetadata dataclass to encapsulate metadata for checkpoint models.
- Included fields for file details, model specifications, and additional attributes such as resolution and architecture.
- Implemented a __post_init__ method to initialize tags as an empty list if not provided, ensuring consistent data handling.
2025-04-05 05:16:52 +08:00
Will Miao
9613199152 Enhance SaveImage functionality with custom prompt support
- Added a new optional parameter `custom_prompt` to the SaveImage class methods to allow users to override the default prompt.
- Updated the `format_metadata` method to utilize the custom prompt if provided.
- Modified the `save_images` and `process_image` methods to accept and pass the custom prompt through the workflow processing.
2025-04-04 07:47:46 +08:00
pixelpaws
14328d7496 Merge pull request #77 from willmiao/dev
Add reconnect functionality for deleted LoRAs in recipe modal
2025-04-03 16:56:04 +08:00
Will Miao
6af12d1acc Add reconnect functionality for deleted LoRAs in recipe modal
- Introduced a new API endpoint to reconnect deleted LoRAs to local files.
- Updated RecipeModal to include UI elements for reconnecting LoRAs, including input fields and buttons.
- Enhanced CSS styles for deleted badges and reconnect containers to improve user experience.
- Implemented event handling for reconnect actions, including input validation and API calls.
- Updated recipe data handling to reflect changes after reconnecting LoRAs.
2025-04-03 16:55:19 +08:00
pixelpaws
9b44e49879 Merge pull request #75 from willmiao/dev
Enhance file monitoring for LoRA files
2025-04-03 11:10:29 +08:00
Will Miao
afee18f146 Enhance file monitoring for LoRA files
- Added a method to map symbolic links back to actual paths in the Config class.
- Improved file creation handling in LoraFileHandler to check for file size and existence before processing.
- Introduced handling for file modification events to update the ignore list and schedule updates.
- Increased debounce delay in _process_changes to allow for file downloads to complete.
- Enhanced action processing to prioritize 'add' actions and verify file existence before adding to cache.
2025-04-03 11:09:30 +08:00
24 changed files with 2843 additions and 542 deletions

View File

@@ -20,6 +20,11 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes ## 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 ### v0.8.3
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility * **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 * **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration

View File

@@ -85,6 +85,17 @@ class Config:
mapped_path = normalized_path.replace(target_path, link_path, 1) mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path return mapped_path
return 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]: def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings""" """Initialize and validate LoRA paths from ComfyUI settings"""

View File

@@ -34,6 +34,7 @@ class SaveImage:
"file_format": (["png", "jpeg", "webp"],), "file_format": (["png", "jpeg", "webp"],),
}, },
"optional": { "optional": {
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
"lossless_webp": ("BOOLEAN", {"default": True}), "lossless_webp": ("BOOLEAN", {"default": True}),
"quality": ("INT", {"default": 100, "min": 1, "max": 100}), "quality": ("INT", {"default": 100, "min": 1, "max": 100}),
"embed_workflow": ("BOOLEAN", {"default": False}), "embed_workflow": ("BOOLEAN", {"default": False}),
@@ -60,7 +61,7 @@ class SaveImage:
return item.get('sha256') return item.get('sha256')
return None 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""" """Format metadata in the requested format similar to userComment example"""
if not parsed_workflow: if not parsed_workflow:
return "" return ""
@@ -69,6 +70,10 @@ class SaveImage:
prompt = parsed_workflow.get('prompt', '') prompt = parsed_workflow.get('prompt', '')
negative_prompt = parsed_workflow.get('negative_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 # Extract loras from the prompt if present
loras_text = parsed_workflow.get('loras', '') loras_text = parsed_workflow.get('loras', '')
lora_hashes = {} lora_hashes = {}
@@ -240,7 +245,8 @@ class SaveImage:
return filename return filename
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None, 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""" """Save images with metadata"""
results = [] results = []
@@ -252,43 +258,45 @@ class SaveImage:
parsed_workflow = {} parsed_workflow = {}
# Get or create metadata asynchronously # 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 # Process filename_prefix with pattern substitution
filename_prefix = self.format_filename(filename_prefix, parsed_workflow) 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): for i, image in enumerate(images):
# Convert the tensor image to numpy array # Convert the tensor image to numpy array
img = 255. * image.cpu().numpy() img = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) 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 # Generate filename with counter if needed
base_filename = filename
if add_counter_to_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 # Set file extension and prepare saving parameters
if file_format == "png": if file_format == "png":
file = filename + ".png" file = base_filename + ".png"
file_extension = ".png" file_extension = ".png"
save_kwargs = {"optimize": True, "compress_level": self.compress_level} save_kwargs = {"optimize": True, "compress_level": self.compress_level}
pnginfo = PngImagePlugin.PngInfo() pnginfo = PngImagePlugin.PngInfo()
elif file_format == "jpeg": elif file_format == "jpeg":
file = filename + ".jpg" file = base_filename + ".jpg"
file_extension = ".jpg" file_extension = ".jpg"
save_kwargs = {"quality": quality, "optimize": True} save_kwargs = {"quality": quality, "optimize": True}
elif file_format == "webp": elif file_format == "webp":
file = filename + ".webp" file = base_filename + ".webp"
file_extension = ".webp" file_extension = ".webp"
save_kwargs = {"quality": quality, "lossless": lossless_webp} save_kwargs = {"quality": quality, "lossless": lossless_webp}
@@ -338,13 +346,17 @@ class SaveImage:
return results return results
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None, 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""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
# Convert single image to list for consistent processing # Ensure images is always a list of images
images = [images[0]] if len(images.shape) == 3 else [img for img in 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 # Save all images
results = self.save_images( results = self.save_images(
@@ -356,7 +368,8 @@ class SaveImage:
lossless_webp, lossless_webp,
quality, quality,
embed_workflow, embed_workflow,
add_counter_to_filename add_counter_to_filename,
custom_prompt if custom_prompt.strip() else None
) )
return (images,) return (images,)

View File

@@ -667,12 +667,28 @@ class ApiRoutes:
"""Handle model move request""" """Handle model move request"""
try: try:
data = await request.json() data = await request.json()
file_path = data.get('file_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') 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: if not file_path or not target_path:
return web.Response(text='File path and target path are required', status=400) 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 # Call scanner to handle the move operation
success = await self.scanner.move_model(file_path, target_path) success = await self.scanner.move_model(file_path, target_path)
@@ -821,33 +837,55 @@ class ApiRoutes:
"""Handle bulk model move request""" """Handle bulk model move request"""
try: try:
data = await request.json() data = await request.json()
file_paths = data.get('file_paths', []) 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') 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: if not file_paths or not target_path:
return web.Response(text='File paths and target path are required', status=400) return web.Response(text='File paths and target path are required', status=400)
results = [] results = []
for file_path in file_paths: 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) 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"]) 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({
return web.json_response({ 'success': True,
'success': True, 'message': f'Moved {success_count} of {len(file_paths)} models',
'message': f'Successfully moved {success_count} models' 'results': results,
}) 'success_count': success_count,
elif success_count > 0: 'failure_count': failure_count
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)
except Exception as e: except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True) logger.error(f"Error moving models in bulk: {e}", exc_info=True)
@@ -962,7 +1000,7 @@ class ApiRoutes:
'base_models': base_models 'base_models': base_models
}) })
except Exception as e: 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({ return web.json_response({
'success': False, 'success': False,
'error': str(e) 'error': str(e)

View File

@@ -89,7 +89,7 @@ class LoraRoutes:
settings=settings, # Pass settings to template settings=settings, # Pass settings to template
request=request # Pass the request object to the 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: except Exception as cache_error:
logger.error(f"Error loading cache data: {cache_error}") logger.error(f"Error loading cache data: {cache_error}")
# 如果获取缓存失败,也显示初始化页面 # 如果获取缓存失败,也显示初始化页面

View File

@@ -53,6 +53,9 @@ class RecipeRoutes:
# Add new endpoint for updating recipe metadata (name and tags) # Add new endpoint for updating recipe metadata (name and tags)
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe) 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 # Start cache initialization
app.on_startup.append(routes._init_cache) app.on_startup.append(routes._init_cache)
@@ -762,7 +765,7 @@ class RecipeRoutes:
return web.json_response({"error": "Invalid workflow JSON"}, status=400) return web.json_response({"error": "Invalid workflow JSON"}, status=400)
if not workflow_json: 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 # Find the latest image in the temp directory
temp_dir = config.temp_directory temp_dir = config.temp_directory
@@ -1021,3 +1024,113 @@ class RecipeRoutes:
except Exception as e: except Exception as e:
logger.error(f"Error updating recipe: {e}", exc_info=True) logger.error(f"Error updating recipe: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500) 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)

View File

@@ -75,17 +75,10 @@ class DownloadManager:
file_size = file_info.get('sizeKB', 0) * 1024 file_size = file_info.get('sizeKB', 0) * 1024
# 4. 通知文件监控系统 - 使用规范化路径和文件大小 # 4. 通知文件监控系统 - 使用规范化路径和文件大小
if self.file_monitor and self.file_monitor.handler: self.file_monitor.handler.add_ignore_path(
# Add both the normalized path and potential alternative paths save_path.replace(os.sep, '/'),
normalized_path = save_path.replace(os.sep, '/') file_size
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)")
# 5. 准备元数据 # 5. 准备元数据
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path) metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)

View File

@@ -2,9 +2,10 @@ from operator import itemgetter
import os import os
import logging import logging
import asyncio import asyncio
import time
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent from watchdog.events import FileSystemEventHandler
from typing import List from typing import List, Dict, Set
from threading import Lock from threading import Lock
from .lora_scanner import LoraScanner from .lora_scanner import LoraScanner
from ..config import config from ..config import config
@@ -20,91 +21,167 @@ class LoraFileHandler(FileSystemEventHandler):
self.pending_changes = set() # 待处理的变更 self.pending_changes = set() # 待处理的变更
self.lock = Lock() # 线程安全锁 self.lock = Lock() # 线程安全锁
self.update_task = None # 异步更新任务 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._min_ignore_timeout = 5 # minimum timeout in seconds
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed 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: def _should_ignore(self, path: str) -> bool:
"""Check if path should be ignored""" """Check if path should be ignored"""
real_path = os.path.realpath(path) # Resolve any symbolic links real_path = os.path.realpath(path) # Resolve any symbolic links
normalized_path = real_path.replace(os.sep, '/') return real_path.replace(os.sep, '/') in self._ignore_paths
# 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
def add_ignore_path(self, path: str, file_size: int = 0): def add_ignore_path(self, path: str, file_size: int = 0):
"""Add path to ignore list with dynamic timeout based on file size""" """Add path to ignore list with dynamic timeout based on file size"""
real_path = os.path.realpath(path) # Resolve any symbolic links 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 # Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
# For small files, use minimum timeout timeout = 5
# 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)")
self.loop.call_later( self.loop.call_later(
timeout, timeout,
self._remove_ignore_path, self._ignore_paths.discard,
normalized_path 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): def on_created(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'): if event.is_directory:
return 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 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): def on_deleted(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'): if event.is_directory or not event.src_path.endswith('.safetensors'):
return return
if self._should_ignore(event.src_path): if self._should_ignore(event.src_path):
return 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}") logger.info(f"LoRA file deleted: {event.src_path}")
self._schedule_update('remove', 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 def _schedule_update(self, action: str, file_path: str): #file_path is a real path
"""Schedule a cache update""" """Schedule a cache update"""
with self.lock: with self.lock:
@@ -141,6 +218,12 @@ class LoraFileHandler(FileSystemEventHandler):
for action, file_path in changes: for action, file_path in changes:
try: try:
if action == 'add': 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 # Scan new file
lora_data = await self.scanner.scan_single_lora(file_path) lora_data = await self.scanner.scan_single_lora(file_path)
if lora_data: if lora_data:

View File

@@ -75,3 +75,31 @@ class LoraMetadata:
self.modified = os.path.getmtime(file_path) self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/') 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 = []

View File

@@ -134,7 +134,7 @@ def transform_lora_loader(inputs: Dict) -> Dict:
"loras": " ".join(lora_texts) "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") result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
return result return result

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" 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." 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"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -584,7 +584,7 @@
font-size: 0.9em; font-size: 0.9em;
} }
/* Deleted badge */ /* Deleted badge with reconnect functionality */
.deleted-badge { .deleted-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -603,6 +603,138 @@
font-size: 0.9em; 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 state */
.recipe-status.partial { .recipe-status.partial {
background: rgba(127, 127, 127, 0.1); background: rgba(127, 127, 127, 0.1);

View File

@@ -31,6 +31,16 @@ class RecipeModal {
!event.target.closest('.edit-icon')) { !event.target.closest('.edit-icon')) {
this.saveTagsEdit(); 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>`; </div>`;
} else if (isDeleted) { } else if (isDeleted) {
localStatus = ` localStatus = `
<div class="deleted-badge"> <div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
<i class="fas fa-trash-alt"></i> Deleted <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>`; </div>`;
} else { } else {
localStatus = ` localStatus = `
@@ -387,7 +398,7 @@ class RecipeModal {
} }
return ` return `
<div class="${loraItemClass}"> <div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
<div class="recipe-lora-thumbnail"> <div class="recipe-lora-thumbnail">
${previewMedia} ${previewMedia}
</div> </div>
@@ -401,11 +412,29 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div> <div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''} ${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</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>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</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>
</div> </div>
`; `;
}).join(''); }).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) // Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
@@ -829,6 +858,155 @@ class RecipeModal {
state.loadingManager.hide(); 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 }; export { RecipeModal };

View File

@@ -173,11 +173,20 @@ class MoveManager {
}) })
}); });
const result = await response.json();
if (!response.ok) { if (!response.ok) {
if (result && result.error) {
throw new Error(result.error);
}
throw new Error('Failed to move model'); 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) { async moveBulkModels(filePaths, targetPath) {
@@ -202,11 +211,44 @@ class MoveManager {
}) })
}); });
const result = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to move models'); 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
View 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>

View File

@@ -1,193 +1,121 @@
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph' import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Size, Vector4 } from '@comfyorg/litegraph'
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
import type { import type {
ICustomWidget, ICustomWidget,
IWidget,
IWidgetOptions IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets' } 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() // BaseDOMWidget properties
/** The unique ID of the widget. */
interface Rect { readonly id: string
height: number /** The node that the widget belongs to. */
width: number readonly node: LGraphNode
x: number /** Whether the widget is visible. */
y: number 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> export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> { extends BaseDOMWidget<V> {
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
type: 'custom'
name: string
element: T element: T
options: DOMWidgetOptions<T, V>
value: V
y?: number
/** /**
* @deprecated Legacy property used by some extensions for customtext * @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. * functionality and works for all DOMWidget types.
*/ */
inputEl?: T 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, * A DOM widget that wraps a Vue component as a litegraph widget.
V extends object | string */
> extends IWidgetOptions { 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 hideOnZoom?: boolean
selectOn?: string[] selectOn?: string[]
onHide?: (widget: DOMWidget<T, V>) => void onHide?: (widget: BaseDOMWidget<V>) => void
getValue?: () => V getValue?: () => V
setValue?: (value: V) => void setValue?: (value: V) => void
getMinHeight?: () => number getMinHeight?: () => number
getMaxHeight?: () => number getMaxHeight?: () => number
getHeight?: () => string | number getHeight?: () => string | number
onDraw?: (widget: DOMWidget<T, V>) => void onDraw?: (widget: BaseDOMWidget<V>) => void
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void margin?: number
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void /**
* @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 { export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
const x = Math.max(a.x, b.x) widget: IWidget
const num1 = Math.min(a.x + a.width, b.x + b.width) ): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
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( export const isComponentWidget = <V extends object | string>(
node: LGraphNode, widget: IWidget
element: HTMLElement, ): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
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 abstract class BaseDOMWidgetImpl<V extends object | string>
const intersection = intersect( implements BaseDOMWidget<V>
{
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' static readonly DEFAULT_MARGIN = 10
name: string readonly type: 'custom'
element: T readonly name: string
options: DOMWidgetOptions<T, V> readonly options: DOMWidgetOptions<V>
computedHeight?: number computedHeight?: number
y: number = 0
callback?: (value: V) => void callback?: (value: V) => void
private mouseDownHandler?: (event: MouseEvent) => void
constructor( readonly id: string
name: string, readonly node: LGraphNode
type: string,
element: T, constructor(obj: {
options: DOMWidgetOptions<T, V> = {} id: string
) { node: LGraphNode
name: string
type: string
options: DOMWidgetOptions<V>
}) {
// @ts-expect-error custom widget type // @ts-expect-error custom widget type
this.type = type this.type = obj.type
this.name = name this.name = obj.name
this.element = element this.options = obj.options
this.options = options
if (element.blur) { this.id = obj.id
this.mouseDownHandler = (event) => { this.node = obj.node
if (!element.contains(event.target as HTMLElement)) {
element.blur()
}
}
document.addEventListener('mousedown', this.mouseDownHandler)
}
} }
get value(): V { get value(): V {
@@ -199,6 +127,67 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.callback?.(this.value) 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 */ /** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) { computeLayoutSize(node: LGraphNode) {
// @ts-expect-error custom widget type // @ts-expect-error custom widget type
@@ -241,69 +230,61 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
minWidth: 0 minWidth: 0
} }
} }
}
draw( export class ComponentWidgetImpl<V extends object | string>
ctx: CanvasRenderingContext2D, extends BaseDOMWidgetImpl<V>
node: LGraphNode, implements ComponentWidget<V>
widgetWidth: number, {
y: number readonly component: Component
): void { readonly inputSpec: InputSpec
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' constructor(obj: {
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true' id: string
const isCollapsed = this.element.dataset.collapsed === 'true' node: LGraphNode
const actualHidden = hidden || !isInVisibleNodes || isCollapsed name: string
const wasHidden = this.element.hidden component: Component
this.element.hidden = actualHidden inputSpec: InputSpec
this.element.style.display = actualHidden ? 'none' : '' options: DOMWidgetOptions<V>
}) {
if (actualHidden && !wasHidden) { super({
this.options.onHide?.(this) ...obj,
} type: 'custom'
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'
}) })
this.component = obj.component
if (useSettingStore().get('Comfy.DOMClippingEnabled')) { this.inputSpec = obj.inputSpec
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 { computeLayoutSize() {
if (this.mouseDownHandler) { const minHeight = this.options.getMinHeight?.() ?? 50
document.removeEventListener('mousedown', this.mouseDownHandler) 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 < LGraphNode.prototype.addDOMWidget = function <
@@ -314,24 +295,19 @@ LGraphNode.prototype.addDOMWidget = function <
name: string, name: string,
type: string, type: string,
element: T, element: T,
options: DOMWidgetOptions<T, V> = {} options: DOMWidgetOptions<V> = {}
): DOMWidget<T, 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 // Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
// Some custom nodes are explicitly expecting getter and setter of `value` // Some custom nodes are explicitly expecting getter and setter of `value`
// property to be on instance instead of prototype. // 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 return widget
} }

View 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
}

View 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
});
}
}
}

View 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 };
}

View File

@@ -1,9 +1,14 @@
import { app } from "../../scripts/app.js"; 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 // Extract pattern into a constant for consistent use
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g; 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) { function mergeLoras(lorasText, lorasArr) {
const result = []; const result = [];
let match; let match;
@@ -44,7 +49,7 @@ app.registerExtension({
}); });
// Wait for node to be properly initialized // Wait for node to be properly initialized
requestAnimationFrame(() => { requestAnimationFrame(async () => {
// Restore saved value if exists // Restore saved value if exists
let existingLoras = []; let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) { if (node.widgets_values && node.widgets_values.length > 0) {
@@ -67,6 +72,10 @@ app.registerExtension({
// Add flag to prevent callback loops // Add flag to prevent callback loops
let isUpdating = false; 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 // Get the widget object directly from the returned object
const result = addLorasWidget(node, "loras", { const result = addLorasWidget(node, "loras", {

View File

@@ -5,19 +5,34 @@ export function addLorasWidget(node, name, opts, callback) {
// Create container for loras // Create container for loras
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "comfy-loras-container"; 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, { Object.assign(container.style, {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "8px", gap: "5px",
padding: "6px", padding: "6px",
backgroundColor: "rgba(40, 44, 52, 0.6)", backgroundColor: "rgba(40, 44, 52, 0.6)",
borderRadius: "6px", borderRadius: "6px",
width: "100%", width: "100%",
boxSizing: "border-box",
overflow: "auto"
}); });
// Initialize default value // Initialize default value
const defaultValue = opts?.defaultVal || []; 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 // Parse LoRA entries from value
const parseLoraValue = (value) => { const parseLoraValue = (value) => {
if (!value) return []; if (!value) return [];
@@ -29,6 +44,23 @@ export function addLorasWidget(node, name, opts, callback) {
return loras; 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 // Function to create toggle element
const createToggle = (active, onChange) => { const createToggle = (active, onChange) => {
const toggle = document.createElement("div"); const toggle = document.createElement("div");
@@ -107,7 +139,7 @@ export function addLorasWidget(node, name, opts, callback) {
return button; return button;
}; };
// 添加预览弹窗组件 // Preview tooltip class
class PreviewTooltip { class PreviewTooltip {
constructor() { constructor() {
this.element = document.createElement('div'); this.element = document.createElement('div');
@@ -122,31 +154,31 @@ export function addLorasWidget(node, name, opts, callback) {
maxWidth: '300px', maxWidth: '300px',
}); });
document.body.appendChild(this.element); document.body.appendChild(this.element);
this.hideTimeout = null; // 添加超时处理变量 this.hideTimeout = null;
// 添加全局点击事件来隐藏tooltip // Add global click event to hide tooltip
document.addEventListener('click', () => this.hide()); document.addEventListener('click', () => this.hide());
// 添加滚动事件监听 // Add scroll event listener
document.addEventListener('scroll', () => this.hide(), true); document.addEventListener('scroll', () => this.hide(), true);
} }
async show(loraName, x, y) { async show(loraName, x, y) {
try { try {
// 清除之前的隐藏定时器 // Clear previous hide timer
if (this.hideTimeout) { if (this.hideTimeout) {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.hideTimeout = null; this.hideTimeout = null;
} }
// 如果已经显示同一个lora的预览则不重复显示 // Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) { if (this.element.style.display === 'block' && this.currentLora === loraName) {
return; return;
} }
this.currentLora = loraName; this.currentLora = loraName;
// 获取预览URL // Get preview URL
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET' method: 'GET'
}); });
@@ -160,7 +192,7 @@ export function addLorasWidget(node, name, opts, callback) {
throw new Error('No preview available'); throw new Error('No preview available');
} }
// 清除现有内容 // Clear existing content
while (this.element.firstChild) { while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild); this.element.removeChild(this.element.firstChild);
} }
@@ -217,7 +249,7 @@ export function addLorasWidget(node, name, opts, callback) {
mediaContainer.appendChild(nameLabel); mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer); this.element.appendChild(mediaContainer);
// 添加淡入效果 // Add fade-in effect
this.element.style.opacity = '0'; this.element.style.opacity = '0';
this.element.style.display = 'block'; this.element.style.display = 'block';
this.position(x, y); this.position(x, y);
@@ -232,20 +264,20 @@ export function addLorasWidget(node, name, opts, callback) {
} }
position(x, y) { position(x, y) {
// 确保预览框不超出视窗边界 // Ensure preview box doesn't exceed viewport boundaries
const rect = this.element.getBoundingClientRect(); const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
let left = x + 10; // 默认在鼠标右侧偏移10px let left = x + 10; // Default 10px offset to the right of mouse
let top = y + 10; // 默认在鼠标下方偏移10px let top = y + 10; // Default 10px offset below mouse
// 检查右边界 // Check right boundary
if (left + rect.width > viewportWidth) { if (left + rect.width > viewportWidth) {
left = x - rect.width - 10; left = x - rect.width - 10;
} }
// 检查下边界 // Check bottom boundary
if (top + rect.height > viewportHeight) { if (top + rect.height > viewportHeight) {
top = y - rect.height - 10; top = y - rect.height - 10;
} }
@@ -257,13 +289,13 @@ export function addLorasWidget(node, name, opts, callback) {
} }
hide() { hide() {
// 使用淡出效果 // Use fade-out effect
if (this.element.style.display === 'block') { if (this.element.style.display === 'block') {
this.element.style.opacity = '0'; this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => { this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none'; this.element.style.display = 'none';
this.currentLora = null; this.currentLora = null;
// 停止视频播放 // Stop video playback
const video = this.element.querySelector('video'); const video = this.element.querySelector('video');
if (video) { if (video) {
video.pause(); video.pause();
@@ -277,14 +309,14 @@ export function addLorasWidget(node, name, opts, callback) {
if (this.hideTimeout) { if (this.hideTimeout) {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
} }
// 移除所有事件监听器 // Remove all event listeners
document.removeEventListener('click', () => this.hide()); document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true); document.removeEventListener('scroll', () => this.hide(), true);
this.element.remove(); this.element.remove();
} }
} }
// 创建预览tooltip实例 // Create preview tooltip instance
const previewTooltip = new PreviewTooltip(); const previewTooltip = new PreviewTooltip();
// Function to create menu item // Function to create menu item
@@ -357,7 +389,7 @@ export function addLorasWidget(node, name, opts, callback) {
padding: '4px 0', padding: '4px 0',
zIndex: 1000, zIndex: 1000,
boxShadow: '0 2px 10px rgba(0,0,0,0.2)', boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
minWidth: '180px', minWidth: '180px',
}); });
// View on Civitai option with globe icon // 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)', 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(deleteOption);
menu.appendChild(separator); menu.appendChild(separator);
menu.appendChild(saveOption); menu.appendChild(saveOption);
@@ -483,12 +515,16 @@ export function addLorasWidget(node, name, opts, callback) {
padding: "20px 0", padding: "20px 0",
color: "rgba(226, 232, 240, 0.8)", color: "rgba(226, 232, 240, 0.8)",
fontStyle: "italic", fontStyle: "italic",
userSelect: "none", // Add this line to prevent text selection userSelect: "none",
WebkitUserSelect: "none", // For Safari support WebkitUserSelect: "none",
MozUserSelect: "none", // For Firefox support MozUserSelect: "none",
msUserSelect: "none", // For IE/Edge support msUserSelect: "none",
width: "100%"
}); });
container.appendChild(emptyMessage); container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
return; return;
} }
@@ -501,7 +537,7 @@ export function addLorasWidget(node, name, opts, callback) {
alignItems: "center", alignItems: "center",
padding: "4px 8px", padding: "4px 8px",
borderBottom: "1px solid rgba(226, 232, 240, 0.2)", borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
marginBottom: "8px" marginBottom: "5px"
}); });
// Add toggle all control // Add toggle all control
@@ -522,10 +558,10 @@ export function addLorasWidget(node, name, opts, callback) {
color: "rgba(226, 232, 240, 0.8)", color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px", fontSize: "13px",
marginLeft: "8px", marginLeft: "8px",
userSelect: "none", // Add this line to prevent text selection userSelect: "none",
WebkitUserSelect: "none", // For Safari support WebkitUserSelect: "none",
MozUserSelect: "none", // For Firefox support MozUserSelect: "none",
msUserSelect: "none", // For IE/Edge support msUserSelect: "none",
}); });
const toggleContainer = document.createElement("div"); const toggleContainer = document.createElement("div");
@@ -543,10 +579,10 @@ export function addLorasWidget(node, name, opts, callback) {
color: "rgba(226, 232, 240, 0.8)", color: "rgba(226, 232, 240, 0.8)",
fontSize: "13px", fontSize: "13px",
marginRight: "8px", marginRight: "8px",
userSelect: "none", // Add this line to prevent text selection userSelect: "none",
WebkitUserSelect: "none", // For Safari support WebkitUserSelect: "none",
MozUserSelect: "none", // For Firefox support MozUserSelect: "none",
msUserSelect: "none", // For IE/Edge support msUserSelect: "none",
}); });
header.appendChild(toggleContainer); header.appendChild(toggleContainer);
@@ -563,11 +599,11 @@ export function addLorasWidget(node, name, opts, callback) {
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
padding: "8px", padding: "6px",
borderRadius: "6px", borderRadius: "6px",
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)", backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
transition: "all 0.2s ease", transition: "all 0.2s ease",
marginBottom: "6px", marginBottom: "4px",
}); });
// Create toggle for this lora // Create toggle for this lora
@@ -595,11 +631,11 @@ export function addLorasWidget(node, name, opts, callback) {
whiteSpace: "nowrap", whiteSpace: "nowrap",
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
fontSize: "13px", fontSize: "13px",
cursor: "pointer", // Add pointer cursor to indicate hoverable area cursor: "pointer",
userSelect: "none", // Add this line to prevent text selection userSelect: "none",
WebkitUserSelect: "none", // For Safari support WebkitUserSelect: "none",
MozUserSelect: "none", // For Firefox support MozUserSelect: "none",
msUserSelect: "none", // For IE/Edge support msUserSelect: "none",
}); });
// Move preview tooltip events to nameEl instead of loraEl // 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); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { 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); const newValue = formatLoraValue(lorasData);
widget.value = newValue; widget.value = newValue;
@@ -669,7 +705,7 @@ export function addLorasWidget(node, name, opts, callback) {
outline: "none", outline: "none",
}); });
// 添加hover效果 // Add hover effect
strengthEl.addEventListener('mouseenter', () => { strengthEl.addEventListener('mouseenter', () => {
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)"; 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.addEventListener('focus', () => {
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)"; strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
strengthEl.style.background = "rgba(0, 0, 0, 0.2)"; strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
// 自动选中所有内容 // Auto-select all content
strengthEl.select(); strengthEl.select();
}); });
@@ -693,29 +729,29 @@ export function addLorasWidget(node, name, opts, callback) {
strengthEl.style.background = "none"; strengthEl.style.background = "none";
}); });
// 处理输入变化 // Handle input changes
strengthEl.addEventListener('change', () => { strengthEl.addEventListener('change', () => {
let newValue = parseFloat(strengthEl.value); let newValue = parseFloat(strengthEl.value);
// 验证输入 // Validate input
if (isNaN(newValue)) { if (isNaN(newValue)) {
newValue = 1.0; newValue = 1.0;
} }
// 更新数值 // Update value
const lorasData = parseLoraValue(widget.value); const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name); const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) { if (loraIndex >= 0) {
lorasData[loraIndex].strength = newValue.toFixed(2); lorasData[loraIndex].strength = newValue.toFixed(2);
// 更新值并触发回调 // Update value and trigger callback
const newLorasValue = formatLoraValue(lorasData); const newLorasValue = formatLoraValue(lorasData);
widget.value = newLorasValue; widget.value = newLorasValue;
} }
}); });
// 处理按键事件 // Handle key events
strengthEl.addEventListener('keydown', (e) => { strengthEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
strengthEl.blur(); strengthEl.blur();
@@ -757,13 +793,17 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(loraEl); 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 // Store the value in a variable to avoid recursion
let widgetValue = defaultValue; let widgetValue = defaultValue;
// Create widget with initial properties // Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "loras", container, { const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() { getValue: function() {
return widgetValue; return widgetValue;
}, },
@@ -778,29 +818,28 @@ export function addLorasWidget(node, name, opts, callback) {
widgetValue = uniqueValue; widgetValue = uniqueValue;
renderLoras(widgetValue, widget); 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() { getMinHeight: function() {
// Calculate height based on content return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
const lorasCount = parseLoraValue(widgetValue).length;
return Math.max(
100,
lorasCount > 0 ? 60 + lorasCount * 44 : 60
);
}, },
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.value = defaultValue;
widget.callback = callback; widget.callback = callback;
widget.serializeValue = () => { widget.serializeValue = () => {
@@ -816,7 +855,7 @@ export function addLorasWidget(node, name, opts, callback) {
previewTooltip.cleanup(); previewTooltip.cleanup();
}; };
return { minWidth: 400, minHeight: 200, widget }; return { minWidth: 400, minHeight: defaultHeight, widget };
} }
// Function to directly save the recipe without dialog // Function to directly save the recipe without dialog
@@ -824,7 +863,6 @@ async function saveRecipeDirectly(widget) {
try { try {
// Get the workflow data from the ComfyUI app // Get the workflow data from the ComfyUI app
const prompt = await app.graphToPrompt(); const prompt = await app.graphToPrompt();
console.log('Prompt:', prompt);
// Show loading toast // Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) { if (app && app.extensionManager && app.extensionManager.toast) {
@@ -879,4 +917,4 @@ async function saveRecipeDirectly(widget) {
}); });
} }
} }
} }

View File

@@ -2,20 +2,36 @@ export function addTagsWidget(node, name, opts, callback) {
// Create container for tags // Create container for tags
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "comfy-tags-container"; 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, { Object.assign(container.style, {
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: "4px", // 从8px减小到4px gap: "4px",
padding: "6px", padding: "6px",
minHeight: "30px", backgroundColor: "rgba(40, 44, 52, 0.6)",
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background borderRadius: "6px",
borderRadius: "6px", // Slightly larger radius
width: "100%", width: "100%",
boxSizing: "border-box",
overflow: "auto",
alignItems: "flex-start" // Ensure tags align at the top of each row
}); });
// Initialize default value as array // Initialize default value as array
const initialTagsData = opts?.defaultVal || []; 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 // Function to render tags from array data
const renderTags = (tagsData, widget) => { const renderTags = (tagsData, widget) => {
// Clear existing tags // Clear existing tags
@@ -38,11 +54,28 @@ export function addTagsWidget(node, name, opts, callback) {
WebkitUserSelect: "none", WebkitUserSelect: "none",
MozUserSelect: "none", MozUserSelect: "none",
msUserSelect: "none", msUserSelect: "none",
width: "100%"
}); });
container.appendChild(emptyMessage); container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
return; 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) => { normalizedTags.forEach((tagData, index) => {
const { text, active } = tagData; const { text, active } = tagData;
const tagEl = document.createElement("div"); const tagEl = document.createElement("div");
@@ -65,44 +98,75 @@ export function addTagsWidget(node, name, opts, callback) {
widget.value = updatedTags; 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 // Helper function to update tag style based on active state
function updateTagStyle(tagEl, active) { function updateTagStyle(tagEl, active) {
const baseStyles = { const baseStyles = {
padding: "4px 12px", // 垂直内边距从6px减小到4px padding: "4px 10px", // Slightly reduced horizontal padding
borderRadius: "6px", // Matching container radius borderRadius: "6px",
maxWidth: "200px", // Increased max width maxWidth: "200px",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
fontSize: "13px", // Slightly larger font fontSize: "13px",
cursor: "pointer", cursor: "pointer",
transition: "all 0.2s ease", // Smoother transition transition: "all 0.2s ease",
border: "1px solid transparent", 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)", boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
margin: "2px", // 从4px减小到2px margin: "1px", // Reduced margin
userSelect: "none", // Add this line to prevent text selection userSelect: "none",
WebkitUserSelect: "none", // For Safari support WebkitUserSelect: "none",
MozUserSelect: "none", // For Firefox support MozUserSelect: "none",
msUserSelect: "none", // For IE/Edge support 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) { if (active) {
Object.assign(tagEl.style, { Object.assign(tagEl.style, {
...baseStyles, ...baseStyles,
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue backgroundColor: "rgba(66, 153, 225, 0.9)",
color: "white", color: "white",
borderColor: "rgba(66, 153, 225, 0.9)", borderColor: "rgba(66, 153, 225, 0.9)",
}); });
} else { } else {
Object.assign(tagEl.style, { Object.assign(tagEl.style, {
...baseStyles, ...baseStyles,
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state backgroundColor: "rgba(45, 55, 72, 0.7)",
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast color: "rgba(226, 232, 240, 0.8)",
borderColor: "rgba(226, 232, 240, 0.2)", borderColor: "rgba(226, 232, 240, 0.2)",
}); });
} }
@@ -122,72 +186,48 @@ export function addTagsWidget(node, name, opts, callback) {
// Store the value as array // Store the value as array
let widgetValue = initialTagsData; let widgetValue = initialTagsData;
// Create widget with initial properties // Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "tags", container, { const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() { getValue: function() {
return widgetValue; return widgetValue;
}, },
setValue: function(v) { setValue: function(v) {
widgetValue = v; widgetValue = v;
renderTags(widgetValue, widget); 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() { getMinHeight: function() {
const minHeight = 150; return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
// 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);
}, },
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; widget.value = initialTagsData;
// Set callback
widget.callback = callback; widget.callback = callback;
// Add serialization method to avoid ComfyUI serialization issues
widget.serializeValue = () => { 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, return [...widgetValue,
{ text: "__dummy_item__", active: false, _isDummy: true }, { text: "__dummy_item__", active: false, _isDummy: true },
{ 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 };
} }

View File

@@ -1,8 +1,11 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.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 // TriggerWordToggle extension for ComfyUI
app.registerExtension({ app.registerExtension({
@@ -26,7 +29,11 @@ app.registerExtension({
}); });
// Wait for node to be properly initialized // 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 // Get the widget object directly from the returned object
const result = addTagsWidget(node, "toggle_trigger_words", { const result = addTagsWidget(node, "toggle_trigger_words", {
defaultVal: [] defaultVal: []

View File

@@ -1,5 +1,32 @@
export const CONVERTED_TYPE = 'converted-widget'; 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 = "") { export function hideWidgetForGood(node, widget, suffix = "") {
widget.origType = widget.type; widget.origType = widget.type;
widget.origComputeSize = widget.computeSize; widget.origComputeSize = widget.computeSize;