Compare commits

..

47 Commits

Author SHA1 Message Date
Will Miao
4882721387 Update version to 0.8.15 and add release notes for enhanced features and improvements 2025-05-16 16:13:37 +08:00
Will Miao
06a8850c0c Add more wiki images 2025-05-16 15:54:52 +08:00
Will Miao
370aa06c67 Refactor duplicates banner styles for improved layout and responsiveness 2025-05-16 15:47:08 +08:00
Will Miao
c9fa0564e7 Update images 2025-05-16 11:36:37 +08:00
Will Miao
2ba7a0ceba Add keyboard navigation support and related styles for enhanced user experience 2025-05-15 20:17:57 +08:00
Will Miao
276aedfbb9 Set 'from_civitai' flag to True when updating local metadata with CivitAI data 2025-05-15 16:50:32 +08:00
Will Miao
c193c75674 Fix misleading error message for invalid civitai api key or early access deny 2025-05-15 13:46:46 +08:00
Will Miao
a562ba3746 Fix TriggerWord Toggle not updating when all LoRAs are disabled 2025-05-15 10:30:46 +08:00
Will Miao
2fedd572ff Add header drag functionality for proportional strength adjustment of LoRAs 2025-05-15 10:12:46 +08:00
Will Miao
db0b49c427 Refactor load_metadata to use save_metadata for updating metadata files 2025-05-15 09:49:30 +08:00
Will Miao
03a6f8111c Add functionality to copy and send LoRA/Recipe syntax to workflow
- Implemented copy functionality for LoRA and Recipe syntax in context menus.
- Added options to send LoRA and Recipe to workflow in both append and replace modes.
- Updated HTML templates to include new context menu items for sending actions.
2025-05-15 07:01:50 +08:00
Will Miao
925ad7b3e0 Add user-select: none to prevent text selection on cards and control elements 2025-05-15 05:36:56 +08:00
Will Miao
bf793d5b8b Refactor Lora and Recipe card event handling: replace copy functionality with direct send to ComfyUI workflow, update UI elements, and enhance sendLoraToWorkflow to support recipe syntax. 2025-05-14 23:51:00 +08:00
Will Miao
64a906ca5e Add Lora syntax send to comfyui functionality: implement API endpoint and frontend integration for sending and updating LoRA codes in ComfyUI nodes. 2025-05-14 21:09:36 +08:00
Will Miao
99b36442bb Enhance metadata processing in ModelScanner: prevent intermediate writes, restore missing civitai data, and ensure base_model consistency. #185 2025-05-14 19:16:58 +08:00
Will Miao
3c5164d510 Update screenshot 2025-05-13 22:56:51 +08:00
Will Miao
ec4b5a4d45 Update release notes and version to v0.8.14: add virtualized scrolling, compact display mode, and enhanced LoRA node functionality. 2025-05-13 22:50:32 +08:00
Will Miao
78e1901779 Add compact mode settings and styles for improved layout control. Fixes #33 2025-05-13 21:40:37 +08:00
Will Miao
cb539314de Ensure full LoRA node chain is considered when updating TriggerWord Toggle nodes 2025-05-13 20:33:52 +08:00
Will Miao
c7627fe0de Remove no longer needed ref files. 2025-05-13 17:57:59 +08:00
Will Miao
84bfad7ce5 Enhance model deletion handling in UI: integrate virtual scroller updates and remove legacy UI card removal logic. 2025-05-13 17:50:28 +08:00
Will Miao
3e06938b05 Add enableDataWindowing option to VirtualScroller for improved control over data fetching. (Disable data windowing for now) 2025-05-13 17:13:17 +08:00
Will Miao
4f712fec14 Reduce default delay in model processing from 0.2 to 0.1 seconds for improved responsiveness. 2025-05-13 15:30:09 +08:00
Will Miao
c5c9659c76 Update refreshModels to pass folder update flag to resetAndReloadFunction 2025-05-13 15:25:40 +08:00
Will Miao
d6e175c1f1 Add API endpoints for retrieving LoRA notes and trigger words; enhance context menu with copy options. Supports #177 2025-05-13 15:14:25 +08:00
Will Miao
88088e1071 Restructure the code of loras_widget into smaller, more manageable modules. 2025-05-13 14:42:28 +08:00
Will Miao
958ddbca86 Fix workaround for saved value retrieval in Loras widget to address custom nodes issue. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/176 2025-05-13 12:27:18 +08:00
Will Miao
6670fd28f4 Add sync functionality for clipStrength when collapsed in Loras widget. https://github.com/willmiao/ComfyUI-Lora-Manager/issues/176 2025-05-13 11:45:13 +08:00
pixelpaws
1e59c31de3 Merge pull request #184 from willmiao/vscroll
Add virtual scroll
2025-05-12 22:27:40 +08:00
Will Miao
c966dbbbbc Enhance DuplicatesManager and VirtualScroller to manage virtual scrolling state and improve rendering logic 2025-05-12 21:31:03 +08:00
Will Miao
af8f5ba04e Implement client-side placeholder handling for empty recipe grid and remove server-side conditional rendering 2025-05-12 21:20:28 +08:00
Will Miao
b741ed0b3b Refactor recipe and checkpoint management to implement virtual scrolling and improve state handling 2025-05-12 20:07:47 +08:00
Will Miao
01ba3c14f8 Implement virtual scrolling for model loading and checkpoint management 2025-05-12 17:47:57 +08:00
Will Miao
d13b1a83ad checkpoint 2025-05-12 16:44:45 +08:00
Will Miao
303477db70 update 2025-05-12 14:50:10 +08:00
Will Miao
311e89e9e7 checkpoint 2025-05-12 13:59:11 +08:00
Will Miao
8546cfe714 checkpoint 2025-05-12 10:25:58 +08:00
Will Miao
e6f4d84b9a Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-05-11 18:50:53 +08:00
Will Miao
ce7e422169 Revert "refactor: streamline LoraCard event handling and implement virtual scrolling for improved performance"
This reverts commit 5dd8d905fa.
2025-05-11 18:50:19 +08:00
pixelpaws
e5aec80984 Merge pull request #179 from jakerdy/patch-1
[Fix] `/api/chekcpoints/info/{name}` change misspelled method call
2025-05-11 17:10:40 +08:00
Jak Erdy
6d97817390 [Fix] /api/chekcpoints/info/{name} change misspelled method call
If you call:
`http://127.0.0.1:8188/api/checkpoints/info/some_name`
You will get error, that there is no method `get_checkpoint_info_by_name` in `scanner`.
Lookslike it wasn't fixed after refactoring or something. Now it works as expected.
2025-05-10 17:38:10 +07:00
Will Miao
d516f22159 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-05-10 07:34:06 +08:00
pixelpaws
e918c18ca2 Create FUNDING.yml 2025-05-09 20:17:35 +08:00
Will Miao
5dd8d905fa refactor: streamline LoraCard event handling and implement virtual scrolling for improved performance 2025-05-09 16:33:34 +08:00
Will Miao
1121d1ee6c Revert "update"
This reverts commit 4793f096af.
2025-05-09 16:14:10 +08:00
Will Miao
4793f096af update 2025-05-09 15:42:56 +08:00
Will Miao
7b5b4ce082 refactor: enhance CFGGuider handling and add CFGGuiderExtractor for improved metadata extraction. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/172 2025-05-09 13:50:22 +08:00
63 changed files with 3940 additions and 2304 deletions

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
ko_fi: pixelpawsai
custom: ['paypal.me/pixelpawsai']

View File

@@ -9,6 +9,7 @@
A comprehensive toolset that streamlines organizing, downloading, and applying LoRA models in ComfyUI. With powerful features like recipe management, checkpoint organization, and one-click workflow integration, working with models becomes faster, smoother, and significantly easier. Access the interface at: `http://localhost:8188/loras`
![Interface Preview](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/screenshot.png)
![One-Click Integration](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/one-click-send.jpg)
## 📺 Tutorial: One-Click LoRA Integration
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
@@ -20,6 +21,17 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.15
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
### v0.8.14
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
### v0.8.13
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links

View File

@@ -187,19 +187,36 @@ class MetadataProcessor:
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
params["sampler"] = sampler_params.get("sampler_name")
# 3. Trace guider input for FluxGuidance and CLIPTextEncode
# 3. Trace guider input for CFGGuider, FluxGuidance and CLIPTextEncode
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
if guider_node_id:
# Look for FluxGuidance along the guider path
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Find CLIPTextEncode for positive prompt (through conditioning)
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
if guider_node_id and guider_node_id in prompt.original_prompt:
# Check if the guider node is a CFGGuider
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
# Extract cfg value from the CFGGuider
if guider_node_id in metadata.get(SAMPLING, {}):
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
params["cfg_scale"] = cfg_params.get("cfg")
# Find CLIPTextEncode for positive prompt
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Find CLIPTextEncode for negative prompt
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
else:
# Look for FluxGuidance along the guider path
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Find CLIPTextEncode for positive prompt (through conditioning)
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else:
# Original tracing for standard samplers

View File

@@ -362,6 +362,23 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id]["parameters"]["guidance"] = guidance_value
class CFGGuiderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "cfg" not in inputs:
return
cfg_value = inputs.get("cfg")
# Store the cfg value in SAMPLING category
if SAMPLING not in metadata:
metadata[SAMPLING] = {}
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
metadata[SAMPLING][node_id]["parameters"]["cfg"] = cfg_value
# Registry of node-specific extractors
NODE_EXTRACTORS = {
# Sampling
@@ -383,6 +400,7 @@ NODE_EXTRACTORS = {
"EmptyLatentImage": ImageSizeExtractor,
# Flux
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
# Image
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
# Add other nodes as needed

View File

@@ -72,6 +72,10 @@ class ApiRoutes:
# Add new endpoint for letter counts
app.router.add_get('/api/loras/letter-counts', routes.get_letter_counts)
# Add new endpoints for copying lora data
app.router.add_get('/api/loras/get-notes', routes.get_lora_notes)
app.router.add_get('/api/loras/get-trigger-words', routes.get_lora_trigger_words)
# Add update check routes
UpdateRoutes.setup_routes(app)
@@ -508,7 +512,7 @@ class ApiRoutes:
logger.warning(f"Early access download failed: {error_message}")
return web.Response(
status=401, # Use 401 status code to match Civitai's response
text=f"Early Access Restriction: {error_message}"
text=error_message
)
return web.Response(status=500, text=error_message)
@@ -1084,3 +1088,81 @@ class ApiRoutes:
'success': False,
'error': str(e)
}, status=500)
async def get_lora_notes(self, request: web.Request) -> web.Response:
"""Get notes for a specific LoRA file"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get lora file name from query parameters
lora_name = request.query.get('name')
if not lora_name:
return web.Response(text='Lora file name is required', status=400)
# Get cache data
cache = await self.scanner.get_cached_data()
# Search for the lora in cache data
for lora in cache.raw_data:
file_name = lora['file_name']
if file_name == lora_name:
notes = lora.get('notes', '')
return web.json_response({
'success': True,
'notes': notes
})
# If lora not found
return web.json_response({
'success': False,
'error': 'LoRA not found in cache'
}, status=404)
except Exception as e:
logger.error(f"Error getting lora notes: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for a specific LoRA file"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get lora file name from query parameters
lora_name = request.query.get('name')
if not lora_name:
return web.Response(text='Lora file name is required', status=400)
# Get cache data
cache = await self.scanner.get_cached_data()
# Search for the lora in cache data
for lora in cache.raw_data:
file_name = lora['file_name']
if file_name == lora_name:
# Get trigger words from civitai data
civitai_data = lora.get('civitai', {})
trigger_words = civitai_data.get('trainedWords', [])
return web.json_response({
'success': True,
'trigger_words': trigger_words
})
# If lora not found
return web.json_response({
'success': False,
'error': 'LoRA not found in cache'
}, status=404)
except Exception as e:
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -430,7 +430,7 @@ class CheckpointsRoutes:
"""Get detailed information for a specific checkpoint by name"""
try:
name = request.match_info.get('name', '')
checkpoint_info = await self.scanner.get_checkpoint_info_by_name(name)
checkpoint_info = await self.scanner.get_model_info_by_name(name)
if checkpoint_info:
return web.json_response(checkpoint_info)

View File

@@ -4,6 +4,7 @@ import asyncio
import json
import time
import aiohttp
from server import PromptServer # type: ignore
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.usage_stats import UsageStats
@@ -49,6 +50,9 @@ class MiscRoutes:
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images)
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images)
# Lora code update endpoint
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
@staticmethod
async def update_settings(request):
"""Update application settings"""
@@ -166,7 +170,7 @@ class MiscRoutes:
output_dir = data.get('output_dir')
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2))
delay = float(data.get('delay', 0.1)) # Default to 0.1 seconds
if not output_dir:
return web.json_response({
@@ -765,3 +769,62 @@ class MiscRoutes:
# Set download status to not downloading
is_downloading = False
@staticmethod
async def update_lora_code(request):
"""
Update Lora code in ComfyUI nodes
Expects a JSON body with:
{
"node_ids": [123, 456], # List of node IDs to update
"lora_code": "<lora:modelname:1.0>", # The Lora code to send
"mode": "append" # or "replace" - whether to append or replace existing code
}
"""
try:
# Parse the request body
data = await request.json()
node_ids = data.get('node_ids', [])
lora_code = data.get('lora_code', '')
mode = data.get('mode', 'append')
if not node_ids or not lora_code:
return web.json_response({
'success': False,
'error': 'Missing node_ids or lora_code parameter'
}, status=400)
# Send the lora code update to each node
results = []
for node_id in node_ids:
try:
# Send the message to the frontend
PromptServer.instance.send_sync("lora_code_update", {
"id": node_id,
"lora_code": lora_code,
"mode": mode
})
results.append({
'node_id': node_id,
'success': True
})
except Exception as e:
logger.error(f"Error sending lora code to node {node_id}: {e}")
results.append({
'node_id': node_id,
'success': False,
'error': str(e)
})
return web.json_response({
'success': True,
'results': results
})
except Exception as e:
logger.error(f"Failed to update lora code: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -6,6 +6,8 @@ import time
import shutil
from typing import List, Dict, Optional, Type, Set
from ..utils.model_utils import determine_base_model
from ..utils.models import BaseModelMetadata
from ..config import config
from ..utils.file_utils import load_metadata, get_file_info, find_preview_file, save_metadata
@@ -537,69 +539,113 @@ class ModelScanner:
# Common methods shared between scanners
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
"""Process a single model file and return its metadata"""
metadata = await load_metadata(file_path, self.model_class)
needs_metadata_update = False
original_save_metadata = save_metadata
if metadata is None:
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
if os.path.exists(civitai_info_path):
try:
with open(civitai_info_path, 'r', encoding='utf-8') as f:
version_info = json.load(f)
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if file_info:
file_name = os.path.splitext(os.path.basename(file_path))[0]
file_info['name'] = file_name
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await save_metadata(file_path, metadata)
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
else:
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
if metadata.civitai is None or metadata.civitai == {}:
# Temporarily override save_metadata to prevent intermediate writes
async def no_op_save(*args, **kwargs):
nonlocal needs_metadata_update
needs_metadata_update = True
return None
# Use a context manager to temporarily replace save_metadata
from contextlib import contextmanager
@contextmanager
def prevent_metadata_writes():
nonlocal needs_metadata_update
# Replace the function temporarily
import sys
from .. import utils
original = utils.file_utils.save_metadata
utils.file_utils.save_metadata = no_op_save
try:
yield
finally:
# Restore the original function
utils.file_utils.save_metadata = original
# Process with write prevention
with prevent_metadata_writes():
metadata = await load_metadata(file_path, self.model_class)
if metadata is None:
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
if os.path.exists(civitai_info_path):
try:
with open(civitai_info_path, 'r', encoding='utf-8') as f:
version_info = json.load(f)
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
metadata.civitai = version_info
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if file_info:
file_name = os.path.splitext(os.path.basename(file_path))[0]
file_info['name'] = file_name
# Ensure tags are also updated if they're missing
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
if 'tags' in version_info['model']:
metadata.tags = version_info['model']['tags']
# Also restore description if missing
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
if 'description' in version_info['model']:
metadata.modelDescription = version_info['model']['description']
# Save the updated metadata
await save_metadata(file_path, metadata)
logger.debug(f"Updated metadata with civitai info for {file_path}")
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
needs_metadata_update = True
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
else:
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
if metadata.civitai is None or metadata.civitai == {}:
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
if os.path.exists(civitai_info_path):
try:
with open(civitai_info_path, 'r', encoding='utf-8') as f:
version_info = json.load(f)
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
metadata.civitai = version_info
needs_metadata_update = True
# Ensure tags are also updated if they're missing
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
if 'tags' in version_info['model']:
metadata.tags = version_info['model']['tags']
needs_metadata_update = True
# Also restore description if missing
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
if 'description' in version_info['model']:
metadata.modelDescription = version_info['model']['description']
needs_metadata_update = True
except Exception as e:
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
# Check if base_model is consistent with civitai baseModel
if metadata.civitai and 'baseModel' in metadata.civitai:
civitai_base_model = determine_base_model(metadata.civitai['baseModel'])
if metadata.base_model != civitai_base_model:
logger.debug(f"Updating base_model from {metadata.base_model} to {civitai_base_model} for {file_path}")
metadata.base_model = civitai_base_model
needs_metadata_update = True
if metadata is None:
metadata = await self._get_file_info(file_path)
if metadata is None:
metadata = await self._get_file_info(file_path)
needs_metadata_update = True
model_data = metadata.to_dict()
# Continue processing
model_data = metadata.to_dict() if metadata else None
# Skip excluded models
if model_data.get('exclude', False):
if model_data and model_data.get('exclude', False):
self._excluded_models.append(model_data['file_path'])
return None
await self._fetch_missing_metadata(file_path, model_data)
# Fetch missing metadata from Civitai if needed (with write prevention)
with prevent_metadata_writes():
await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path)
model_data['folder'] = folder.replace(os.path.sep, '/')
# Only save metadata if needed
if needs_metadata_update and metadata:
await original_save_metadata(file_path, metadata)
return model_data
async def _fetch_missing_metadata(self, file_path: str, model_data: Dict) -> None:
@@ -651,9 +697,9 @@ class ModelScanner:
model_data['civitai']['creator'] = model_metadata['creator']
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(model_data, f, indent=2, ensure_ascii=False)
# Create a metadata object and save it using save_metadata
metadata_obj = self.model_class.from_dict(model_data)
await save_metadata(file_path, metadata_obj)
except Exception as e:
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")

View File

@@ -245,8 +245,7 @@ async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = L
# needs_update = True
if needs_update:
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
save_metadata(file_path, model_class.from_dict(data))
return model_class.from_dict(data)

View File

@@ -40,6 +40,7 @@ class ModelRouteUtils:
civitai_metadata: Dict, client: CivitaiClient) -> None:
"""Update local metadata with CivitAI data"""
local_metadata['civitai'] = civitai_metadata
local_metadata['from_civitai'] = True
# Update model name if available
if 'model' in civitai_metadata:

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.13"
version = "0.8.15"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -1,12 +1,13 @@
/* 卡片网格布局 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
gap: 12px; /* Consistent gap for both row and column spacing */
row-gap: 20px; /* Increase vertical spacing between rows */
margin-top: var(--space-2);
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
max-width: 1400px; /* Container width control */
max-width: 1400px; /* Base container width */
margin-left: auto;
margin-right: auto;
}
@@ -17,13 +18,14 @@
border-radius: var(--border-radius-base);
backdrop-filter: blur(16px);
transition: transform 160ms ease-out;
aspect-ratio: 896/1152;
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */
width: 100%;
margin: 0 auto;
cursor: pointer; /* Added from recipe-card */
display: flex; /* Added from recipe-card */
flex-direction: column; /* Added from recipe-card */
overflow: hidden; /* Add overflow hidden to contain children */
cursor: pointer;
display: flex;
flex-direction: column;
overflow: hidden;
}
.lora-card:hover {
@@ -36,6 +38,30 @@
outline-offset: 2px;
}
/* Responsive adjustments for 1440p screens (2K) */
@media (min-width: 2000px) {
.card-grid {
max-width: 1800px; /* Increased for 2K screens */
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
}
.lora-card {
max-width: 270px;
}
}
/* Responsive adjustments for 4K screens */
@media (min-width: 3000px) {
.card-grid {
max-width: 2400px; /* Increased for 4K screens */
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.lora-card {
max-width: 280px;
}
}
/* Responsive adjustments */
@media (max-width: 1400px) {
.card-grid {
@@ -58,6 +84,26 @@
min-height: 0; /* Fix for potential flexbox sizing issue in Firefox */
}
/* Smaller text for compact mode */
.compact-mode .model-name {
font-size: 0.9em;
max-height: 2.4em;
}
.compact-mode .base-model-label {
font-size: 0.8em;
max-width: 110px;
}
.compact-mode .card-actions i {
font-size: 0.95em;
padding: 3px;
}
.compact-mode .model-info {
padding-bottom: 2px;
}
.card-preview img,
.card-preview video {
width: 100%;
@@ -313,6 +359,25 @@
font-size: 0.85em;
}
/* Prevent text selection on cards and interactive elements */
.lora-card,
.lora-card *,
.card-actions,
.card-actions i,
.toggle-blur-btn,
.show-content-btn,
.card-preview img,
.card-preview video,
.card-footer,
.card-header,
.model-name,
.base-model-label {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Recipe specific elements - migrated from recipe-card.css */
.recipe-indicator {
position: absolute;
@@ -362,4 +427,42 @@
padding: 2rem;
background: var(--lora-surface-alt);
border-radius: var(--border-radius-base);
}
/* Virtual scrolling specific styles - updated */
.virtual-scroll-item {
position: absolute;
box-sizing: border-box;
transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
width: 100%; /* Allow width to be set by the VirtualScroller */
}
.virtual-scroll-item:hover {
transform: translateY(-2px); /* Keep hover effect */
z-index: 1; /* Ensure hovered items appear above others */
}
/* When using virtual scroll, adjust container */
.card-grid.virtual-scroll {
display: block;
position: relative;
margin: 0 auto;
padding: 6px 0; /* Add top/bottom padding equivalent to card padding */
height: auto;
width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */
}
/* For larger screens, allow more space for the cards */
@media (min-width: 2000px) {
.card-grid.virtual-scroll {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.card-grid.virtual-scroll {
max-width: 2400px;
}
}

View File

@@ -2,25 +2,38 @@
/* Duplicates banner */
.duplicates-banner {
position: sticky;
top: 48px; /* Match header height */
left: 0;
position: relative; /* Changed from sticky to relative */
width: 100%;
background-color: var(--card-bg);
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
z-index: var(--z-overlay);
padding: 12px 16px;
padding: 12px 0; /* Removed horizontal padding */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
margin-bottom: 20px; /* Add margin to create space below the banner */
}
.duplicates-banner .banner-content {
max-width: 1400px;
max-width: 1400px; /* Match the container max-width */
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px; /* Move horizontal padding to the content */
}
/* Responsive container for larger screens - match container in layout.css */
@media (min-width: 2000px) {
.duplicates-banner .banner-content {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.duplicates-banner .banner-content {
max-width: 2400px;
}
}
.duplicates-banner i.fa-exclamation-triangle {

View File

@@ -0,0 +1,96 @@
/* Keyboard navigation indicator and help */
.keyboard-nav-hint {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: help;
transition: all 0.2s ease;
margin-left: 8px;
}
.keyboard-nav-hint:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
}
.keyboard-nav-hint i {
font-size: 14px;
}
/* Tooltip styling */
.tooltip {
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 240px;
background-color: var(--lora-surface);
color: var(--text-color);
text-align: center;
border-radius: var(--border-radius-xs);
padding: 8px;
position: absolute;
z-index: 9999; /* 确保在卡片上方显示 */
left: 120%; /* 将tooltip显示在图标右侧 */
top: 50%; /* 垂直居中 */
transform: translateY(-50%); /* 垂直居中 */
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--lora-border);
font-size: 0.85em;
line-height: 1.4;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 50%; /* 箭头垂直居中 */
right: 100%; /* 箭头在左侧 */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
/* Keyboard shortcuts table */
.keyboard-shortcuts {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
}
.keyboard-shortcuts td {
padding: 4px;
text-align: left;
}
.keyboard-shortcuts td:first-child {
font-weight: bold;
width: 40%;
}
.key {
display: inline-block;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.8em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}

View File

@@ -672,4 +672,14 @@ input:checked + .toggle-slider:before {
.changelog-item a:hover {
text-decoration: underline;
}
/* Add warning text style for settings */
.warning-text {
color: var(--lora-warning, #e67e22);
font-weight: 500;
}
[data-theme="dark"] .warning-text {
color: var(--lora-warning, #f39c12);
}

View File

@@ -15,6 +15,19 @@
z-index: var(--z-base);
}
/* Responsive container for larger screens */
@media (min-width: 2000px) {
.container {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.container {
max-width: 2400px;
}
}
.controls {
display: flex;
flex-direction: column;
@@ -22,6 +35,13 @@
margin-bottom: var(--space-2);
}
.controls-right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto; /* Push to the right */
}
.actions {
display: flex;
align-items: center;
@@ -293,6 +313,26 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Prevent text selection in control and header areas */
.tag,
.control-group button,
.control-group select,
.toggle-folders-btn,
.bulk-operations-panel,
.app-header,
.header-branding,
.app-title,
.main-nav,
.nav-item,
.header-actions button,
.header-controls,
.toggle-folders-container button {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (max-width: 768px) {
.actions {
flex-wrap: wrap;
@@ -305,11 +345,14 @@
width: 100%;
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
}
.toggle-folders-container {
margin-left: 0;
width: 100%;
display: flex;
justify-content: flex-end;
}
.folder-tags-container {

View File

@@ -23,6 +23,7 @@
@import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
.initialization-notice {
display: flex;

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,7 +1,6 @@
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js';
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
/**
@@ -160,6 +159,231 @@ export async function loadMoreModels(options = {}) {
}
}
// New method for virtual scrolling fetch
export async function fetchModelsPage(options = {}) {
const {
modelType = 'lora',
page = 1,
pageSize = 100,
endpoint = '/api/loras'
} = options;
const pageState = getCurrentPageState();
try {
const params = new URLSearchParams({
page: page,
page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy
});
if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
// Add favorites filter parameter if enabled
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
// Add active letter filter if set
if (pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
params.append('fuzzy', 'true');
// Add search option parameters if available
if (pageState.searchOptions) {
params.append('search_filename', pageState.searchOptions.filename.toString());
params.append('search_modelname', pageState.searchOptions.modelname.toString());
if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters if active
if (pageState.filters) {
// Handle tags filters
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
if (modelType === 'checkpoint') {
pageState.filters.tags.forEach(tag => {
params.append('tag', tag);
});
} else {
params.append('tags', pageState.filters.tags.join(','));
}
}
// Handle base model filters
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
if (modelType === 'checkpoint') {
pageState.filters.baseModel.forEach(model => {
params.append('base_model', model);
});
} else {
params.append('base_models', pageState.filters.baseModel.join(','));
}
}
}
// Add model-specific parameters
if (modelType === 'lora') {
// Check for recipe-based filtering parameters from session storage
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
// Add hash filter parameter if present
if (filterLoraHash) {
params.append('lora_hash', filterLoraHash);
}
// Add multiple hashes filter if present
else if (filterLoraHashes) {
try {
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
params.append('lora_hashes', filterLoraHashes.join(','));
}
} catch (error) {
console.error('Error parsing lora hashes from session storage:', error);
}
}
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
const data = await response.json();
return {
items: data.items,
totalItems: data.total,
totalPages: data.total_pages,
currentPage: page,
hasMore: page < data.total_pages,
folders: data.folders
};
} catch (error) {
console.error(`Error fetching ${modelType}s:`, error);
showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error');
throw error;
}
}
/**
* Reset and reload models using virtual scrolling
* @param {Object} options - Operation options
* @returns {Promise<Object>} The fetch result
*/
export async function resetAndReloadWithVirtualScroll(options = {}) {
const {
modelType = 'lora',
updateFolders = false,
fetchPageFunction
} = options;
const pageState = getCurrentPageState();
try {
pageState.isLoading = true;
document.body.classList.add('loading');
// Reset page counter
pageState.currentPage = 1;
// Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50);
// Update the virtual scroller
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2
// Update folders if needed
if (updateFolders && result.folders) {
updateFolderTags(result.folders);
}
return result;
} catch (error) {
console.error(`Error reloading ${modelType}s:`, error);
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
throw error;
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
/**
* Load more models using virtual scrolling
* @param {Object} options - Operation options
* @returns {Promise<Object>} The fetch result
*/
export async function loadMoreWithVirtualScroll(options = {}) {
const {
modelType = 'lora',
resetPage = false,
updateFolders = false,
fetchPageFunction
} = options;
const pageState = getCurrentPageState();
try {
// Start loading state
pageState.isLoading = true;
document.body.classList.add('loading');
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
}
// Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
// Update virtual scroller with the new data
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2
// Update folders if needed
if (updateFolders && result.folders) {
updateFolderTags(result.folders);
}
return result;
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
throw error;
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
// Update folder tags in the UI
export function updateFolderTags(folders) {
const folderTagsContainer = document.querySelector('.folder-tags');
@@ -231,10 +455,15 @@ export async function deleteModel(filePath, modelType = 'lora') {
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
// If virtual scroller exists, update its data
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
} else {
// Legacy approach: remove the card from UI directly
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
}
showToast(`${modelType} deleted successfully`, 'success');
@@ -283,7 +512,7 @@ export async function refreshModels(options = {}) {
}
if (typeof resetAndReloadFunction === 'function') {
await resetAndReloadFunction();
await resetAndReloadFunction(true); // update folders
}
showToast(`Refresh complete`, 'success');
@@ -449,10 +678,15 @@ export async function excludeModel(filePath, modelType = 'lora') {
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
// If virtual scroller exists, update its data
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
} else {
// Legacy approach: remove the card from UI directly
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
}
showToast(`${modelType} excluded successfully`, 'success');

View File

@@ -1,7 +1,10 @@
import { createCheckpointCard } from '../components/CheckpointCard.js';
import {
loadMoreModels,
fetchModelsPage,
resetAndReload as baseResetAndReload,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
@@ -9,25 +12,67 @@ import {
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state } from '../state/index.js';
// Load more checkpoints with pagination
export async function loadMoreCheckpoints(resetPagination = true) {
return loadMoreModels({
resetPage: resetPagination,
updateFolders: true,
/**
* Fetch checkpoints with pagination for virtual scrolling
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} Object containing items, total count, and pagination info
*/
export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
return fetchModelsPage({
modelType: 'checkpoint',
createCardFunction: createCheckpointCard,
page,
pageSize,
endpoint: '/api/checkpoints'
});
}
/**
* Load more checkpoints with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<void>}
*/
export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
// Check if virtual scroller is available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
modelType: 'checkpoint',
resetPage,
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} else {
// Fall back to the original implementation if virtual scroller isn't available
return loadMoreModels({
resetPage,
updateFolders,
modelType: 'checkpoint',
createCardFunction: createCheckpointCard,
endpoint: '/api/checkpoints'
});
}
}
// Reset and reload checkpoints
export async function resetAndReload() {
return baseResetAndReload({
updateFolders: true,
modelType: 'checkpoint',
loadMoreFunction: loadMoreCheckpoints
});
export async function resetAndReload(updateFolders = false) {
// Check if virtual scroller is available
if (state.virtualScroller) {
return resetAndReloadWithVirtualScroll({
modelType: 'checkpoint',
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} else {
// Fall back to original implementation
return baseResetAndReload({
updateFolders,
modelType: 'checkpoint',
loadMoreFunction: loadMoreCheckpoints
});
}
}
// Refresh checkpoints
@@ -60,7 +105,11 @@ export async function fetchCivitai() {
// Refresh single checkpoint metadata
export async function refreshSingleCheckpointMetadata(filePath) {
return refreshSingleModelMetadata(filePath, 'checkpoint');
const success = await refreshSingleModelMetadata(filePath, 'checkpoint');
if (success) {
// Reload the current view to show updated data
await resetAndReload();
}
}
/**

View File

@@ -1,7 +1,10 @@
import { createLoraCard } from '../components/LoraCard.js';
import {
loadMoreModels,
fetchModelsPage,
resetAndReload as baseResetAndReload,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
@@ -9,6 +12,7 @@ import {
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state, getCurrentPageState } from '../state/index.js';
/**
* Save model metadata to the server
@@ -44,12 +48,46 @@ export async function excludeLora(filePath) {
return baseExcludeModel(filePath, 'lora');
}
/**
* Load more loras with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<void>}
*/
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreModels({
resetPage,
updateFolders,
const pageState = getCurrentPageState();
// Check if virtual scroller is available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
modelType: 'lora',
resetPage,
updateFolders,
fetchPageFunction: fetchLorasPage
});
} else {
// Fall back to the original implementation if virtual scroller isn't available
return loadMoreModels({
resetPage,
updateFolders,
modelType: 'lora',
createCardFunction: createLoraCard,
endpoint: '/api/loras'
});
}
}
/**
* Fetch loras with pagination for virtual scrolling
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} Object containing items, total count, and pagination info
*/
export async function fetchLorasPage(page = 1, pageSize = 100) {
return fetchModelsPage({
modelType: 'lora',
createCardFunction: createLoraCard,
page,
pageSize,
endpoint: '/api/loras'
});
}
@@ -71,21 +109,38 @@ export async function replacePreview(filePath) {
}
export function appendLoraCards(loras) {
const grid = document.getElementById('loraGrid');
const sentinel = document.getElementById('scroll-sentinel');
loras.forEach(lora => {
const card = createLoraCard(lora);
grid.appendChild(card);
});
// This function is no longer needed with virtual scrolling
// but kept for compatibility
if (state.virtualScroller) {
console.warn('appendLoraCards is deprecated when using virtual scrolling');
} else {
const grid = document.getElementById('loraGrid');
loras.forEach(lora => {
const card = createLoraCard(lora);
grid.appendChild(card);
});
}
}
export async function resetAndReload(updateFolders = false) {
return baseResetAndReload({
updateFolders,
modelType: 'lora',
loadMoreFunction: loadMoreLoras
});
const pageState = getCurrentPageState();
// Check if virtual scroller is available
if (state.virtualScroller) {
return resetAndReloadWithVirtualScroll({
modelType: 'lora',
updateFolders,
fetchPageFunction: fetchLorasPage
});
} else {
// Fall back to original implementation
return baseResetAndReload({
updateFolders,
modelType: 'lora',
loadMoreFunction: loadMoreLoras
});
}
}
export async function refreshLoras() {

174
static/js/api/recipeApi.js Normal file
View File

@@ -0,0 +1,174 @@
import { RecipeCard } from '../components/RecipeCard.js';
import {
fetchModelsPage,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll
} from './baseModelApi.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} Object containing items, total count, and pagination info
*/
export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState();
try {
const params = new URLSearchParams({
page: page,
page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy
});
// If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe
const response = await fetch(`/api/recipe/${pageState.customFilter.recipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
}
const recipe = await response.json();
// Return in expected format
return {
items: [recipe],
totalItems: 1,
totalPages: 1,
currentPage: 1,
hasMore: false
};
}
// Add custom filter for Lora if present
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true');
} else {
// Normal filtering logic
// Add search filter if present
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
// Add search option parameters
if (pageState.searchOptions) {
params.append('search_title', pageState.searchOptions.title.toString());
params.append('search_tags', pageState.searchOptions.tags.toString());
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
params.append('fuzzy', 'true');
}
}
// Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
params.append('base_models', pageState.filters.baseModel.join(','));
}
// Add tag filters
if (pageState.filters?.tags && pageState.filters.tags.length) {
params.append('tags', pageState.filters.tags.join(','));
}
}
// Fetch recipes
const response = await fetch(`/api/recipes?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
}
const data = await response.json();
return {
items: data.items,
totalItems: data.total,
totalPages: data.total_pages,
currentPage: page,
hasMore: page < data.total_pages
};
} catch (error) {
console.error('Error fetching recipes:', error);
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
throw error;
}
}
/**
* Reset and reload recipes using virtual scrolling
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<Object>} The fetch result
*/
export async function resetAndReload(updateFolders = false) {
return resetAndReloadWithVirtualScroll({
modelType: 'recipe',
updateFolders,
fetchPageFunction: fetchRecipesPage
});
}
/**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/
export async function refreshRecipes() {
try {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache
const response = await fetch('/api/recipes/scan');
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache');
}
// After successful cache rebuild, reload the recipes
await resetAndReload();
showToast('Refresh complete', 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
showToast(error.message || 'Failed to refresh recipes', 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
/**
* Load more recipes with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
* @returns {Promise<void>}
*/
export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState();
// Use virtual scroller if available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
modelType: 'recipe',
resetPage,
updateFolders: false,
fetchPageFunction: fetchRecipesPage
});
}
}
/**
* Create a recipe card instance from recipe data
* @param {Object} recipe - Recipe data
* @returns {HTMLElement} Recipe card DOM element
*/
export function createRecipeCard(recipe) {
const recipeCard = new RecipeCard(recipe, (recipe) => {
if (window.recipeManager) {
window.recipeManager.showRecipeDetails(recipe);
}
});
return recipeCard.element;
}

View File

@@ -1,5 +1,4 @@
import { appCore } from './core.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js';
@@ -40,9 +39,6 @@ class CheckpointsPageManager {
// Initialize context menu
new CheckpointContextMenu();
// Initialize infinite scroll
initializeInfiniteScroll('checkpoints');
// Initialize common page features
appCore.initializePageFeatures();

View File

@@ -1,6 +1,6 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
@@ -35,7 +35,16 @@ export class LoraContextMenu extends BaseContextMenu {
}
break;
case 'copyname':
this.currentCard.querySelector('.fa-copy')?.click();
// Generate and copy LoRA syntax
this.copyLoraSyntax();
break;
case 'sendappend':
// Send LoRA to workflow (append mode)
this.sendLoraToWorkflow(false);
break;
case 'sendreplace':
// Send LoRA to workflow (replace mode)
this.sendLoraToWorkflow(true);
break;
case 'preview':
this.currentCard.querySelector('.fa-image')?.click();
@@ -58,6 +67,26 @@ export class LoraContextMenu extends BaseContextMenu {
}
}
// New method to handle copy syntax functionality
copyLoraSyntax() {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
}
// New method to handle send to workflow functionality
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
// NSFW Selector methods from the original context menu
initNSFWSelector() {
// Close button

View File

@@ -1,5 +1,5 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { showToast } from '../../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { state } from '../../state/index.js';
@@ -39,8 +39,16 @@ export class RecipeContextMenu extends BaseContextMenu {
this.currentCard.click();
break;
case 'copy':
// Copy recipe to clipboard
this.currentCard.querySelector('.fa-copy')?.click();
// Copy recipe syntax to clipboard
this.copyRecipeSyntax();
break;
case 'sendappend':
// Send recipe to workflow (append mode)
this.sendRecipeToWorkflow(false);
break;
case 'sendreplace':
// Send recipe to workflow (replace mode)
this.sendRecipeToWorkflow(true);
break;
case 'share':
// Share recipe
@@ -61,6 +69,52 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
// New method to copy recipe syntax to clipboard
copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot copy recipe: Missing recipe ID', 'error');
return;
}
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to copy recipe syntax: ', err);
showToast('Failed to copy recipe syntax', 'error');
});
}
// New method to send recipe to workflow
sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot send recipe: Missing recipe ID', 'error');
return;
}
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to send recipe to workflow', 'error');
});
}
// View all LoRAs in the recipe
viewRecipeLoRAs(recipeId) {
if (!recipeId) {

View File

@@ -1,7 +1,7 @@
// Duplicates Manager Component
import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { getCurrentPageState } from '../state/index.js';
import { state, getCurrentPageState } from '../state/index.js';
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager {
@@ -61,10 +61,9 @@ export class DuplicatesManager {
banner.style.display = 'block';
}
// Disable infinite scroll
if (this.recipeManager.observer) {
this.recipeManager.observer.disconnect();
this.recipeManager.observer = null;
// Disable virtual scrolling if active
if (state.virtualScroller) {
state.virtualScroller.disable();
}
// Add duplicate-mode class to the body
@@ -94,13 +93,21 @@ export class DuplicatesManager {
// Remove duplicate-mode class from the body
document.body.classList.remove('duplicate-mode');
// Reload normal recipes view
this.recipeManager.loadRecipes();
// Clear the recipe grid first
const recipeGrid = document.getElementById('recipeGrid');
if (recipeGrid) {
recipeGrid.innerHTML = '';
}
// Reinitialize infinite scroll
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 500);
// Re-enable virtual scrolling
if (state.virtualScroller) {
state.virtualScroller.enable();
} else {
// If virtual scroller doesn't exist, reinitialize it
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
}
}
renderDuplicateGroups() {

View File

@@ -1,4 +1,4 @@
import { showToast, openCivitai, copyToClipboard } from '../utils/uiHelpers.js';
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js';
@@ -6,6 +6,182 @@ import { NSFW_LEVELS } from '../utils/constants.js';
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
import { showDeleteModal } from '../utils/modalUtils.js';
// Add a global event delegation handler
export function setupLoraCardEventDelegation() {
const gridElement = document.getElementById('loraGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', handleLoraCardEvent);
// Add the event delegation handler
gridElement.addEventListener('click', handleLoraCardEvent);
}
// Event delegation handler for all lora card events
function handleLoraCardEvent(event) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.name);
}
return;
}
if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation();
sendLoraToComfyUI(card, event.shiftKey);
return;
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
replacePreview(card.dataset.filepath);
return;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else {
// Normal behavior - show modal
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
// Attempt to parse the JSON string
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
showLoraModal(loraMeta);
}
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
// Function to send LoRA to ComfyUI workflow
async function sendLoraToComfyUI(card, replaceMode) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
export function createLoraCard(lora) {
const card = document.createElement('div');
card.className = 'lora-card';
@@ -94,8 +270,8 @@ export function createLoraCard(lora) {
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i>
<i class="fas fa-copy"
title="Copy LoRA Syntax">
<i class="fas fa-paper-plane"
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
</i>
<i class="fas fa-trash"
title="Delete Model">
@@ -123,162 +299,12 @@ export function createLoraCard(lora) {
</div>
`;
// Main card click event - modified to handle bulk mode
card.addEventListener('click', () => {
// Check if we're in bulk mode
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else {
// Normal behavior - show modal
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
// Attempt to parse the JSON string
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
showLoraModal(loraMeta);
}
});
// Toggle blur button functionality
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = toggleBlurBtn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
}
// Show content button functionality
const showContentBtn = card.querySelector('.show-content-btn');
if (showContentBtn) {
showContentBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
}
// Favorite button click event
card.querySelector('.fa-star')?.addEventListener('click', async e => {
e.stopPropagation();
const starIcon = e.currentTarget;
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
});
// Copy button click event
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
e.stopPropagation();
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
});
// Civitai button click event
if (lora.from_civitai) {
card.querySelector('.fa-globe')?.addEventListener('click', e => {
e.stopPropagation();
openCivitai(lora.model_name);
});
}
// Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation();
showDeleteModal(lora.file_path);
});
// Replace preview button click event
card.querySelector('.fa-image')?.addEventListener('click', e => {
e.stopPropagation();
replacePreview(lora.file_path);
});
// Apply bulk mode styling if currently in bulk mode
if (state.bulkMode) {
const actions = card.querySelectorAll('.card-actions');
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
// Add a special class for virtual scroll positioning if needed
if (state.virtualScroller) {
card.classList.add('virtual-scroll-item');
}
// Add autoplayOnHover handlers for video elements if needed
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
@@ -287,15 +313,10 @@ export function createLoraCard(lora) {
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause
cardPreview.addEventListener('mouseenter', () => {
videoElement.play();
});
cardPreview.addEventListener('mouseleave', () => {
videoElement.pause();
videoElement.currentTime = 0;
});
// Add mouse events to trigger play/pause using event attributes
// This approach reduces the number of event listeners created
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
@@ -308,7 +329,7 @@ export function updateCardsForBulkMode(isBulkMode) {
document.body.classList.toggle('bulk-mode', isBulkMode);
// Get all lora cards
// Get all lora cards - this can now be from the DOM or through the virtual scroller
const loraCards = document.querySelectorAll('.lora-card');
loraCards.forEach(card => {
@@ -330,6 +351,11 @@ export function updateCardsForBulkMode(isBulkMode) {
}
});
// If using virtual scroller, we need to rerender after toggling bulk mode
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
state.virtualScroller.scheduleRender();
}
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();

View File

@@ -1,5 +1,5 @@
// Recipe Card Component
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
@@ -52,7 +52,7 @@ class RecipeCard {
</div>
<div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i>
</div>
</div>
@@ -94,10 +94,10 @@ class RecipeCard {
this.shareRecipe();
});
// Copy button click event - prevent propagation to card
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
// Send button click event - prevent propagation to card
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
e.stopPropagation();
this.copyRecipeSyntax();
this.sendRecipeToWorkflow(e.shiftKey);
});
// Delete button click event - prevent propagation to card
@@ -108,33 +108,32 @@ class RecipeCard {
}
}
copyRecipeSyntax() {
// Replace copyRecipeSyntax with sendRecipeToWorkflow
sendRecipeToWorkflow(replaceMode = false) {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
showToast('Cannot send recipe: Missing recipe ID', 'error');
return;
}
// Fallback if button not found
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to copy: ', err);
showToast('Failed to copy recipe syntax', 'error');
console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to send recipe to workflow', 'error');
});
} catch (error) {
console.error('Error copying recipe syntax:', error);
showToast('Error copying recipe syntax', 'error');
console.error('Error sending recipe to workflow:', error);
showToast('Error sending recipe to workflow', 'error');
}
}

View File

@@ -37,7 +37,7 @@ export class PageControls {
*/
initializeState() {
// Set default values
this.pageState.pageSize = 20;
this.pageState.pageSize = 100;
this.pageState.isLoading = false;
this.pageState.hasMore = true;

View File

@@ -2,6 +2,7 @@
import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js';
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
// Export the classes
export { PageControls, LorasControls, CheckpointsControls };
@@ -20,4 +21,17 @@ export function createPageControls(pageType) {
console.error(`Unknown page type: ${pageType}`);
return null;
}
}
// Example for a filter method:
function applyFilter(filterType, value) {
// ...existing filter logic...
// After filters are applied, refresh the virtual scroll if it exists
if (state.virtualScroller) {
refreshVirtualScroll();
} else {
// Fall back to existing reset and reload logic
resetAndReload(true);
}
}

View File

@@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
// Core application class
export class AppCore {
@@ -63,7 +64,12 @@ export class AppCore {
// Initialize lazy loading for images on all pages
lazyLoadImages();
// Initialize infinite scroll for pages that need it
// Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') {
setupLoraCardEventDelegation();
}
// Initialize virtual scroll for pages that need it
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
initializeInfiniteScroll(pageType);
}

View File

@@ -63,8 +63,14 @@ class LoraPageManager {
// Initialize the bulk manager
bulkManager.initialize();
// Initialize common page features (lazy loading, infinite scroll)
// Initialize common page features (virtual scroll)
appCore.initializePageFeatures();
// Add virtual scroll class to grid for CSS adjustments
const loraGrid = document.getElementById('loraGrid');
if (loraGrid && state.virtualScroller) {
loraGrid.classList.add('virtual-scroll');
}
}
}

View File

@@ -26,6 +26,11 @@ export class SettingsManager {
if (savedSettings) {
state.global.settings = { ...state.global.settings, ...savedSettings };
}
// Initialize default values for new settings if they don't exist
if (state.global.settings.compactMode === undefined) {
state.global.settings.compactMode = false;
}
}
initialize() {
@@ -76,6 +81,12 @@ export class SettingsManager {
if (autoplayOnHoverCheckbox) {
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
}
// Set compact mode setting
const compactModeCheckbox = document.getElementById('compactMode');
if (compactModeCheckbox) {
compactModeCheckbox.checked = state.global.settings.compactMode || false;
}
// Load default lora root
await this.loadLoraRoots();
@@ -149,6 +160,8 @@ export class SettingsManager {
state.global.settings.autoplayOnHover = value;
} else if (settingKey === 'optimize_example_images') {
state.global.settings.optimizeExampleImages = value;
} else if (settingKey === 'compact_mode') {
state.global.settings.compactMode = value;
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -185,6 +198,12 @@ export class SettingsManager {
this.reloadContent();
}
// Recalculate layout when compact mode changes
if (settingKey === 'compact_mode' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
showToast(`Compact Mode ${value ? 'enabled' : 'disabled'}`, 'success');
}
} catch (error) {
showToast('Failed to save setting: ' + error.message, 'error');
}

View File

@@ -1,13 +1,13 @@
// Recipe manager module
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { RecipeCard } from './components/RecipeCard.js';
import { RecipeModal } from './components/RecipeModal.js';
import { getCurrentPageState } from './state/index.js';
import { getCurrentPageState, state } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js';
import { resetAndReload, refreshRecipes } from './api/recipeApi.js';
class RecipeManager {
constructor() {
@@ -27,8 +27,8 @@ class RecipeManager {
this.pageState.isLoading = false;
this.pageState.hasMore = true;
// Custom filter state
this.customFilter = {
// Custom filter state - move to pageState for compatibility with virtual scrolling
this.pageState.customFilter = {
active: false,
loraName: null,
loraHash: null,
@@ -49,13 +49,10 @@ class RecipeManager {
// Check for custom filter parameters in session storage
this._checkCustomFilter();
// Load initial set of recipes
await this.loadRecipes();
// Expose necessary functions to the page
this._exposeGlobalFunctions();
// Initialize common page features (lazy loading, infinite scroll)
// Initialize common page features
appCore.initializePageFeatures();
}
@@ -87,7 +84,7 @@ class RecipeManager {
// Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) {
this.customFilter = {
this.pageState.customFilter = {
active: true,
loraName: filterLoraName,
loraHash: filterLoraHash,
@@ -108,11 +105,11 @@ class RecipeManager {
// Update text based on filter type
let filterText = '';
if (this.customFilter.recipeId) {
if (this.pageState.customFilter.recipeId) {
filterText = 'Viewing specific recipe';
} else if (this.customFilter.loraName) {
} else if (this.pageState.customFilter.loraName) {
// Format with Lora name
const loraName = this.customFilter.loraName;
const loraName = this.pageState.customFilter.loraName;
const displayName = loraName.length > 25 ?
loraName.substring(0, 22) + '...' :
loraName;
@@ -125,8 +122,8 @@ class RecipeManager {
// Update indicator text and show it
textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip
if (this.customFilter.loraName) {
textElement.setAttribute('title', this.customFilter.loraName);
if (this.pageState.customFilter.loraName) {
textElement.setAttribute('title', this.pageState.customFilter.loraName);
}
indicator.classList.remove('hidden');
@@ -149,7 +146,7 @@ class RecipeManager {
_clearCustomFilter() {
// Reset custom filter
this.customFilter = {
this.pageState.customFilter = {
active: false,
loraName: null,
loraHash: null,
@@ -167,8 +164,8 @@ class RecipeManager {
removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId');
// Reload recipes without custom filter
this.loadRecipes();
// Reset and refresh the virtual scroller
refreshVirtualScroll();
}
initEventListeners() {
@@ -177,105 +174,21 @@ class RecipeManager {
if (sortSelect) {
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
this.loadRecipes();
refreshVirtualScroll();
});
}
}
// This method is kept for compatibility but now uses virtual scrolling
async loadRecipes(resetPage = true) {
try {
// Skip loading if in duplicates mode
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return;
}
// Show loading indicator
document.body.classList.add('loading');
this.pageState.isLoading = true;
// Reset to first page if requested
if (resetPage) {
this.pageState.currentPage = 1;
// Clear grid if resetting
const grid = document.getElementById('recipeGrid');
if (grid) grid.innerHTML = '';
}
// If we have a specific recipe ID to load
if (this.customFilter.active && this.customFilter.recipeId) {
await this._loadSpecificRecipe(this.customFilter.recipeId);
return;
}
// Build query parameters
const params = new URLSearchParams({
page: this.pageState.currentPage,
page_size: this.pageState.pageSize || 20,
sort_by: this.pageState.sortBy
});
// Add custom filter for Lora if present
if (this.customFilter.active && this.customFilter.loraHash) {
params.append('lora_hash', this.customFilter.loraHash);
// Skip other filters when using custom filter
params.append('bypass_filters', 'true');
} else {
// Normal filtering logic
// Add search filter if present
if (this.pageState.filters.search) {
params.append('search', this.pageState.filters.search);
// Add search option parameters
if (this.pageState.searchOptions) {
params.append('search_title', this.pageState.searchOptions.title.toString());
params.append('search_tags', this.pageState.searchOptions.tags.toString());
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
params.append('fuzzy', 'true');
}
}
// Add base model filters
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
params.append('base_models', this.pageState.filters.baseModel.join(','));
}
// Add tag filters
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
params.append('tags', this.pageState.filters.tags.join(','));
}
}
// Fetch recipes
const response = await fetch(`/api/recipes?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
}
const data = await response.json();
// Update recipes grid
this.updateRecipesGrid(data, resetPage);
// Update pagination state based on current page and total pages
this.pageState.hasMore = data.page < data.total_pages;
// Increment the page number AFTER successful loading
if (data.items.length > 0) {
this.pageState.currentPage++;
}
} catch (error) {
console.error('Error loading recipes:', error);
appCore.showToast('Failed to load recipes', 'error');
} finally {
// Hide loading indicator
document.body.classList.remove('loading');
this.pageState.isLoading = false;
// Skip loading if in duplicates mode
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return;
}
if (resetPage) {
refreshVirtualScroll();
}
}
@@ -283,95 +196,7 @@ class RecipeManager {
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/
async refreshRecipes() {
try {
// Call the new endpoint to rebuild the recipe cache
const response = await fetch('/api/recipes/scan');
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache');
}
// After successful cache rebuild, load the recipes
await this.loadRecipes(true);
appCore.showToast('Refresh complete', 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
appCore.showToast(error.message || 'Failed to refresh recipes', 'error');
// Still try to load recipes even if scan failed
await this.loadRecipes(true);
}
}
async _loadSpecificRecipe(recipeId) {
try {
// Fetch specific recipe by ID
const response = await fetch(`/api/recipe/${recipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
}
const recipe = await response.json();
// Create a data structure that matches the expected format
const recipeData = {
items: [recipe],
total: 1,
page: 1,
page_size: 1,
total_pages: 1
};
// Update grid with single recipe
this.updateRecipesGrid(recipeData, true);
// Pagination not needed for single recipe
this.pageState.hasMore = false;
// Show recipe details modal
setTimeout(() => {
this.showRecipeDetails(recipe);
}, 300);
} catch (error) {
console.error('Error loading specific recipe:', error);
appCore.showToast('Failed to load recipe details', 'error');
// Clear the filter and show all recipes
this._clearCustomFilter();
}
}
updateRecipesGrid(data, resetGrid = true) {
const grid = document.getElementById('recipeGrid');
if (!grid) return;
// Check if data exists and has items
if (!data.items || data.items.length === 0) {
if (resetGrid) {
grid.innerHTML = `
<div class="placeholder-message">
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
</div>
`;
}
return;
}
// Clear grid if resetting
if (resetGrid) {
grid.innerHTML = '';
}
// Create recipe cards
data.items.forEach(recipe => {
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
grid.appendChild(recipeCard.element);
});
return refreshRecipes();
}
showRecipeDetails(recipe) {
@@ -396,8 +221,18 @@ class RecipeManager {
}
exitDuplicateMode() {
// Clear the grid first to prevent showing old content temporarily
const recipeGrid = document.getElementById('recipeGrid');
if (recipeGrid) {
recipeGrid.innerHTML = '';
}
this.duplicatesManager.exitDuplicateMode();
initializeInfiniteScroll();
// Use a small delay before initializing to ensure DOM is ready
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
}
}

View File

@@ -0,0 +1,934 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from './uiHelpers.js';
export class VirtualScroller {
constructor(options) {
// Configuration
this.gridElement = options.gridElement;
this.createItemFn = options.createItemFn;
this.fetchItemsFn = options.fetchItemsFn;
this.overscan = options.overscan || 5; // Extra items to render above/below viewport
this.containerElement = options.containerElement || this.gridElement.parentElement;
this.scrollContainer = options.scrollContainer || this.containerElement;
this.batchSize = options.batchSize || 50;
this.pageSize = options.pageSize || 100;
this.itemAspectRatio = 896/1152; // Aspect ratio of cards
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add data windowing enable/disable flag
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
// State
this.items = []; // All items metadata
this.renderedItems = new Map(); // Map of rendered DOM elements by index
this.totalItems = 0;
this.isLoading = false;
this.hasMore = true;
this.lastScrollTop = 0;
this.scrollDirection = 'down';
this.lastRenderRange = { start: 0, end: 0 };
this.pendingScroll = null;
this.resizeObserver = null;
// Data windowing parameters
this.windowSize = options.windowSize || 2000; // ±1000 items from current view
this.windowPadding = options.windowPadding || 500; // Buffer before loading more
this.dataWindow = { start: 0, end: 0 }; // Current data window indices
this.absoluteWindowStart = 0; // Start index in absolute terms
this.fetchingWindow = false; // Flag to track window fetching state
// Responsive layout state
this.itemWidth = 0;
this.itemHeight = 0;
this.columnsCount = 0;
this.gridPadding = 12; // Gap between cards
this.columnGap = 12; // Horizontal gap
// Add loading timeout state
this.loadingTimeout = null;
this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default
// Initialize
this.initializeContainer();
this.setupEventListeners();
this.calculateLayout();
}
initializeContainer() {
// Add virtual scroll class to grid
this.gridElement.classList.add('virtual-scroll');
// Set the container to have relative positioning
if (getComputedStyle(this.containerElement).position === 'static') {
this.containerElement.style.position = 'relative';
}
// Create a spacer element with the total height
this.spacerElement = document.createElement('div');
this.spacerElement.className = 'virtual-scroll-spacer';
this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
this.spacerElement.style.pointerEvents = 'none';
// The grid will be used for the actual visible items
this.gridElement.style.position = 'relative';
this.gridElement.style.minHeight = '0';
// Place the spacer inside the grid container
this.gridElement.appendChild(this.spacerElement);
}
calculateLayout() {
// Get container width and style information
const containerWidth = this.containerElement.clientWidth;
const containerStyle = getComputedStyle(this.containerElement);
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
// Calculate available content width (excluding padding)
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
// Get compact mode setting
const compactMode = state.global.settings?.compactMode || false;
// Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth;
// Match exact column counts and CSS container width values
if (window.innerWidth >= 3000) { // 4K
maxColumns = compactMode ? 10 : 8;
maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2000) { // 2K/1440p
maxColumns = compactMode ? 8 : 6;
maxGridWidth = 1800; // Match exact CSS container width for 2K
} else {
// 1080p
maxColumns = compactMode ? 7 : 5;
maxGridWidth = 1400; // Match exact CSS container width for 1080p
}
// Calculate baseCardWidth based on desired column count and available space
// Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
// Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
// Set exact column count based on screen size and mode
this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space
this.columnsCount = Math.max(1, Math.floor(
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
));
}
// Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Log layout info
console.log('Virtual Scroll Layout:', {
containerWidth,
availableContentWidth,
actualGridWidth,
columnsCount: this.columnsCount,
itemWidth: this.itemWidth,
itemHeight: this.itemHeight,
leftOffset: this.leftOffset,
paddingLeft,
paddingRight,
compactMode,
maxColumns,
baseCardWidth,
rowGap: this.rowGap
});
// Update grid element max-width to match available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
// Add or remove compact-mode class for style adjustments
if (compactMode) {
this.gridElement.classList.add('compact-mode');
} else {
this.gridElement.classList.remove('compact-mode');
}
// Update spacer height
this.updateSpacerHeight();
// Re-render with new layout
this.clearRenderedItems();
this.scheduleRender();
return true;
}
setupEventListeners() {
// Debounced scroll handler
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Window resize handler for layout recalculation
this.resizeHandler = this.debounce(() => {
this.calculateLayout();
}, 150);
window.addEventListener('resize', this.resizeHandler);
// Use ResizeObserver for more accurate container size detection
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(this.debounce(() => {
this.calculateLayout();
}, 150));
this.resizeObserver.observe(this.containerElement);
}
}
async initialize() {
try {
await this.loadInitialBatch();
this.scheduleRender();
} catch (err) {
console.error('Failed to initialize virtual scroller:', err);
showToast('Failed to load items', 'error');
}
}
async loadInitialBatch() {
const pageState = getCurrentPageState();
if (this.isLoading) return;
this.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety
try {
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
// Initialize the data window with the first batch of items
this.items = items || [];
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
this.dataWindow = { start: 0, end: this.items.length };
this.absoluteWindowStart = 0;
// Update the spacer height based on the total number of items
this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed
if (this.items.length === 0) {
this.showNoItemsPlaceholder();
} else {
this.removeNoItemsPlaceholder();
}
// Reset page state to sync with our virtual scroller
pageState.currentPage = 2; // Next page to load would be 2
pageState.hasMore = this.hasMore;
pageState.isLoading = false;
return { items, totalItems, hasMore };
} catch (err) {
console.error('Failed to load initial batch:', err);
this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.');
throw err;
} finally {
this.isLoading = false;
this.clearLoadingTimeout(); // Clear the timeout
}
}
async loadMoreItems() {
const pageState = getCurrentPageState();
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
pageState.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety
try {
console.log('Loading more items, page:', pageState.currentPage);
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
if (items && items.length > 0) {
this.items = [...this.items, ...items];
this.hasMore = hasMore;
pageState.hasMore = hasMore;
// Update page for next request
pageState.currentPage++;
// Update the spacer height
this.updateSpacerHeight();
// Render the newly loaded items if they're in view
this.scheduleRender();
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
} else {
this.hasMore = false;
pageState.hasMore = false;
console.log('No more items to load');
}
return items;
} catch (err) {
console.error('Failed to load more items:', err);
showToast('Failed to load more items', 'error');
} finally {
this.isLoading = false;
pageState.isLoading = false;
this.clearLoadingTimeout(); // Clear the timeout
}
}
// Add new methods for loading timeout
setLoadingTimeout() {
// Clear any existing timeout first
this.clearLoadingTimeout();
// Set a new timeout to prevent loading state from getting stuck
this.loadingTimeout = setTimeout(() => {
if (this.isLoading) {
console.warn('Loading timeout occurred. Resetting loading state.');
this.isLoading = false;
const pageState = getCurrentPageState();
pageState.isLoading = false;
}
}, this.loadingTimeoutDuration);
}
clearLoadingTimeout() {
if (this.loadingTimeout) {
clearTimeout(this.loadingTimeout);
this.loadingTimeout = null;
}
}
updateSpacerHeight() {
if (this.columnsCount === 0) return;
// Calculate total rows needed based on total items and columns
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
// Add row gaps to the total height calculation
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
// Update spacer height to represent all items
this.spacerElement.style.height = `${totalHeight}px`;
}
getVisibleRange() {
const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight;
// Calculate the visible row range, accounting for row gaps
const rowHeight = this.itemHeight + this.rowGap;
const startRow = Math.floor(scrollTop / rowHeight);
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
// Add overscan for smoother scrolling
const overscanRows = this.overscan;
const firstRow = Math.max(0, startRow - overscanRows);
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
// Calculate item indices
const firstIndex = firstRow * this.columnsCount;
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
return { start: firstIndex, end: lastIndex };
}
// Update the scheduleRender method to check for disabled state
scheduleRender() {
if (this.disabled || this.renderScheduled) return;
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderItems();
this.renderScheduled = false;
});
}
// Update the renderItems method to check for disabled state
renderItems() {
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
const { start, end } = this.getVisibleRange();
// Check if render range has significantly changed
const isSameRange =
start >= this.lastRenderRange.start &&
end <= this.lastRenderRange.end &&
Math.abs(start - this.lastRenderRange.start) < 10;
if (isSameRange) return;
this.lastRenderRange = { start, end };
// Determine which items need to be added and removed
const currentIndices = new Set();
for (let i = start; i < end && i < this.items.length; i++) {
currentIndices.add(i);
}
// Remove items that are no longer visible
for (const [index, element] of this.renderedItems.entries()) {
if (!currentIndices.has(index)) {
element.remove();
this.renderedItems.delete(index);
}
}
// Use DocumentFragment for batch DOM operations
const fragment = document.createDocumentFragment();
// Add new visible items to the fragment
for (let i = start; i < end && i < this.items.length; i++) {
if (!this.renderedItems.has(i)) {
const item = this.items[i];
const element = this.createItemElement(item, i);
fragment.appendChild(element);
this.renderedItems.set(i, element);
}
}
// Add the fragment to the grid (single DOM operation)
if (fragment.childNodes.length > 0) {
this.gridElement.appendChild(fragment);
}
// If we're close to the end and have more items to load, fetch them
if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
this.loadMoreItems();
}
// Check if we need to slide the data window
this.slideDataWindow();
}
clearRenderedItems() {
this.renderedItems.forEach(element => element.remove());
this.renderedItems.clear();
this.lastRenderRange = { start: 0, end: 0 };
}
refreshWithData(items, totalItems, hasMore) {
this.items = items || [];
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed
if (this.items.length === 0) {
this.showNoItemsPlaceholder();
} else {
this.removeNoItemsPlaceholder();
}
// Clear all rendered items and redraw
this.clearRenderedItems();
this.scheduleRender();
}
createItemElement(item, index) {
// Create the DOM element
const element = this.createItemFn(item);
// Add virtual scroll item class
element.classList.add('virtual-scroll-item');
// Calculate the position
const row = Math.floor(index / this.columnsCount);
const col = index % this.columnsCount;
// Calculate precise positions with row gap included
const topPos = row * (this.itemHeight + this.rowGap);
// Position correctly with leftOffset (no need to add padding as absolute
// positioning is already relative to the padding edge of the container)
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
// Position the element with absolute positioning
element.style.position = 'absolute';
element.style.left = `${leftPos}px`;
element.style.top = `${topPos}px`;
element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`;
return element;
}
handleScroll() {
// Determine scroll direction
const scrollTop = this.scrollContainer.scrollTop;
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
this.lastScrollTop = scrollTop;
// Handle large jumps in scroll position - check if we need to fetch a new window
const { scrollHeight } = this.scrollContainer;
const scrollRatio = scrollTop / scrollHeight;
// Only perform data windowing if the feature is enabled
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
const currentWindowStart = this.absoluteWindowStart;
const currentWindowEnd = currentWindowStart + this.items.length;
// If the estimated position is outside our current window by a significant amount
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
// Fetch a new data window centered on the estimated position
this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2)));
return; // Skip normal rendering until new data is loaded
}
}
// Render visible items
this.scheduleRender();
// If we're near the bottom and have more items, load them
const { clientHeight } = this.scrollContainer;
const scrollBottom = scrollTop + clientHeight;
// Fix the threshold calculation - use percentage of remaining height instead
// We'll trigger loading when within 20% of the bottom of rendered content
const remainingScroll = scrollHeight - scrollBottom;
const scrollThreshold = Math.min(
// Either trigger when within 20% of the total height from bottom
scrollHeight * 0.2,
// Or when within 2 rows of content from the bottom, whichever is larger
(this.itemHeight + this.rowGap) * 2
);
const shouldLoadMore = remainingScroll <= scrollThreshold;
if (shouldLoadMore && this.hasMore && !this.isLoading) {
this.loadMoreItems();
}
}
// Method to fetch data for a specific window position
async fetchDataWindow(targetIndex) {
// Skip if data windowing is disabled or already fetching
if (!this.enableDataWindowing || this.fetchingWindow) return;
this.fetchingWindow = true;
try {
// Calculate which page we need to fetch based on target index
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
if (items && items.length > 0) {
// Calculate new absolute window start
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
// Replace the entire data window with new items
this.items = items;
this.dataWindow = {
start: 0,
end: items.length
};
this.totalItems = totalItems || 0;
this.hasMore = hasMore;
// Update the current page for future fetches
const pageState = getCurrentPageState();
pageState.currentPage = targetPage + 1;
pageState.hasMore = hasMore;
// Update the spacer height and clear current rendered items
this.updateSpacerHeight();
this.clearRenderedItems();
this.scheduleRender();
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
}
} catch (err) {
console.error('Failed to fetch data window:', err);
showToast('Failed to load items at this position', 'error');
} finally {
this.fetchingWindow = false;
}
}
// Method to slide the data window if we're approaching its edges
async slideDataWindow() {
// Skip if data windowing is disabled
if (!this.enableDataWindowing) return;
const { start, end } = this.getVisibleRange();
const windowStart = this.dataWindow.start;
const windowEnd = this.dataWindow.end;
const absoluteIndex = this.absoluteWindowStart + windowStart;
// Calculate the midpoint of the visible range
const visibleMidpoint = Math.floor((start + end) / 2);
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
// Check if we're too close to the window edges
const closeToStart = start - windowStart < this.windowPadding;
const closeToEnd = windowEnd - end < this.windowPadding;
// If we're close to either edge and have total items > window size
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
// Calculate a new target index centered around the current viewport
const halfWindow = Math.floor(this.windowSize / 2);
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
// Don't fetch a new window if we're already showing items near the beginning
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
return;
}
// Don't fetch if we're showing the end of the list and are near the end
if (this.absoluteWindowStart + this.items.length >= this.totalItems &&
this.totalItems - end < halfWindow) {
return;
}
// Fetch the new data window
await this.fetchDataWindow(targetIndex);
}
}
reset() {
// Remove all rendered items
this.clearRenderedItems();
// Reset state
this.items = [];
this.totalItems = 0;
this.hasMore = true;
// Reset spacer height
this.spacerElement.style.height = '0px';
// Remove any placeholder
this.removeNoItemsPlaceholder();
// Schedule a re-render
this.scheduleRender();
}
dispose() {
// Remove event listeners
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
window.removeEventListener('resize', this.resizeHandler);
// Clean up the resize observer if present
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
// Remove rendered elements
this.clearRenderedItems();
// Remove spacer
this.spacerElement.remove();
// Remove virtual scroll class
this.gridElement.classList.remove('virtual-scroll');
// Clear any pending timeout
this.clearLoadingTimeout();
}
// Add methods to handle placeholder display
showNoItemsPlaceholder(message) {
// Remove any existing placeholder first
this.removeNoItemsPlaceholder();
// Create placeholder message
const placeholder = document.createElement('div');
placeholder.className = 'placeholder-message';
// Determine appropriate message based on page type
let placeholderText = '';
if (message) {
placeholderText = message;
} else {
const pageType = state.currentPageType;
if (pageType === 'recipes') {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
} else if (pageType === 'loras') {
placeholderText = `
<p>No LoRAs found</p>
<p>Add LoRAs to your models folder to see them here.</p>
`;
} else if (pageType === 'checkpoints') {
placeholderText = `
<p>No checkpoints found</p>
<p>Add checkpoints to your models folder to see them here.</p>
`;
} else {
placeholderText = `
<p>No items found</p>
<p>Try adjusting your search filters or add more content.</p>
`;
}
}
placeholder.innerHTML = placeholderText;
placeholder.id = 'virtualScrollPlaceholder';
// Append placeholder to the grid
this.gridElement.appendChild(placeholder);
}
removeNoItemsPlaceholder() {
const placeholder = document.getElementById('virtualScrollPlaceholder');
if (placeholder) {
placeholder.remove();
}
}
// Utility method for debouncing
debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Add disable method to stop rendering and events
disable() {
// Detach scroll event listener
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
// Clear all rendered items from the DOM
this.clearRenderedItems();
// Hide the spacer element
if (this.spacerElement) {
this.spacerElement.style.display = 'none';
}
// Flag as disabled
this.disabled = true;
console.log('Virtual scroller disabled');
}
// Add enable method to resume rendering and events
enable() {
if (!this.disabled) return;
// Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Show the spacer element
if (this.spacerElement) {
this.spacerElement.style.display = 'block';
}
// Flag as enabled
this.disabled = false;
// Re-render items
this.scheduleRender();
console.log('Virtual scroller enabled');
}
// New method to remove an item by file path
removeItemByFilePath(filePath) {
if (!filePath || this.disabled || this.items.length === 0) return false;
// Find the index of the item with the matching file path
const index = this.items.findIndex(item =>
item.file_path === filePath ||
item.filepath === filePath ||
item.path === filePath
);
if (index === -1) {
console.warn(`Item with file path ${filePath} not found in virtual scroller data`);
return false;
}
// Remove the item from the data array
this.items.splice(index, 1);
// Decrement total count
this.totalItems = Math.max(0, this.totalItems - 1);
// Remove the item from rendered items if it exists
if (this.renderedItems.has(index)) {
this.renderedItems.get(index).remove();
this.renderedItems.delete(index);
}
// Shift all rendered items with higher indices down by 1
const indicesToUpdate = [];
// Collect all indices that need to be updated
for (const [idx, element] of this.renderedItems.entries()) {
if (idx > index) {
indicesToUpdate.push(idx);
}
}
// Update the elements and map entries
for (const idx of indicesToUpdate) {
const element = this.renderedItems.get(idx);
this.renderedItems.delete(idx);
// The item is now at the previous index
this.renderedItems.set(idx - 1, element);
}
// Update the spacer height to reflect the new total
this.updateSpacerHeight();
// Re-render to ensure proper layout
this.clearRenderedItems();
this.scheduleRender();
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
return true;
}
// Add keyboard navigation methods
handlePageUpDown(direction) {
// Prevent duplicate animations by checking last trigger time
const now = Date.now();
if (this.lastPageNavTime && now - this.lastPageNavTime < 300) {
return; // Ignore rapid repeated triggers
}
this.lastPageNavTime = now;
const scrollContainer = this.scrollContainer;
const viewportHeight = scrollContainer.clientHeight;
// Calculate scroll distance (one viewport minus 10% overlap for context)
const scrollDistance = viewportHeight * 0.9;
// Determine the new scroll position
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
// Remove any existing transition indicators
this.removeExistingTransitionIndicator();
// Scroll to the new position with smooth animation
scrollContainer.scrollTo({
top: newScrollTop,
behavior: 'smooth'
});
// Page transition indicator removed
// this.showTransitionIndicator();
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
setTimeout(() => this.renderItems(), 300);
}
// Helper to remove existing indicators
removeExistingTransitionIndicator() {
const existingIndicator = document.querySelector('.page-transition-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
}
// Create a more contained transition indicator - commented out as it's no longer needed
/*
showTransitionIndicator() {
const container = this.containerElement;
const indicator = document.createElement('div');
indicator.className = 'page-transition-indicator';
// Get container position to properly position the indicator
const containerRect = container.getBoundingClientRect();
// Style the indicator to match just the container area
indicator.style.position = 'fixed';
indicator.style.top = `${containerRect.top}px`;
indicator.style.left = `${containerRect.left}px`;
indicator.style.width = `${containerRect.width}px`;
indicator.style.height = `${containerRect.height}px`;
document.body.appendChild(indicator);
// Remove after animation completes
setTimeout(() => {
if (indicator.parentNode) {
indicator.remove();
}
}, 500);
}
*/
scrollToTop() {
this.removeExistingTransitionIndicator();
// Page transition indicator removed
// this.showTransitionIndicator();
this.scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
}
scrollToBottom() {
this.removeExistingTransitionIndicator();
// Page transition indicator removed
// this.showTransitionIndicator();
// Start loading all remaining pages to ensure content is available
this.loadRemainingPages().then(() => {
// After loading all content, scroll to the very bottom
const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
this.scrollContainer.scrollTo({
top: maxScroll,
behavior: 'smooth'
});
});
}
// New method to load all remaining pages
async loadRemainingPages() {
// If we're already at the end or loading, don't proceed
if (!this.hasMore || this.isLoading) return;
console.log('Loading all remaining pages for End key navigation...');
// Keep loading pages until we reach the end
while (this.hasMore && !this.isLoading) {
await this.loadMoreItems();
// Force render after each page load
this.renderItems();
// Small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log('Finished loading all pages');
// Final render to ensure all content is displayed
this.renderItems();
}
}

View File

@@ -1,12 +1,53 @@
import { state, getCurrentPageState } from '../state/index.js';
import { loadMoreLoras } from '../api/loraApi.js';
import { loadMoreCheckpoints } from '../api/checkpointApi.js';
import { debounce } from './debounce.js';
import { VirtualScroller } from './VirtualScroller.js';
import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js';
import { createCheckpointCard } from '../components/CheckpointCard.js';
import { fetchLorasPage } from '../api/loraApi.js';
import { fetchCheckpointsPage } from '../api/checkpointApi.js';
import { showToast } from './uiHelpers.js';
export function initializeInfiniteScroll(pageType = 'loras') {
// Clean up any existing observer
if (state.observer) {
state.observer.disconnect();
// Function to dynamically import the appropriate card creator based on page type
async function getCardCreator(pageType) {
if (pageType === 'loras') {
return createLoraCard;
} else if (pageType === 'recipes') {
// Import the RecipeCard module
const { RecipeCard } = await import('../components/RecipeCard.js');
// Return a wrapper function that creates a recipe card element
return (recipe) => {
const recipeCard = new RecipeCard(recipe, (recipe) => {
if (window.recipeManager) {
window.recipeManager.showRecipeDetails(recipe);
}
});
return recipeCard.element;
};
} else if (pageType === 'checkpoints') {
return createCheckpointCard;
}
return null;
}
// Function to get the appropriate data fetcher based on page type
async function getDataFetcher(pageType) {
if (pageType === 'loras') {
return fetchLorasPage;
} else if (pageType === 'recipes') {
// Import the recipeApi module and use the fetchRecipesPage function
const { fetchRecipesPage } = await import('../api/recipeApi.js');
return fetchRecipesPage;
} else if (pageType === 'checkpoints') {
return fetchCheckpointsPage;
}
return null;
}
export async function initializeInfiniteScroll(pageType = 'loras') {
// Clean up any existing virtual scroller
if (state.virtualScroller) {
state.virtualScroller.dispose();
state.virtualScroller = null;
}
// Set the current page type
@@ -17,109 +58,155 @@ export function initializeInfiniteScroll(pageType = 'loras') {
// Skip initializing if in duplicates mode (for recipes page)
if (pageType === 'recipes' && pageState.duplicatesMode) {
console.log('Skipping virtual scroll initialization - duplicates mode is active');
return;
}
// Determine the load more function and grid ID based on page type
let loadMoreFunction;
// Use virtual scrolling for all page types
await initializeVirtualScroll(pageType);
// Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') {
setupLoraCardEventDelegation();
}
}
async function initializeVirtualScroll(pageType) {
// Determine the grid ID based on page type
let gridId;
switch (pageType) {
case 'recipes':
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
window.recipeManager.loadRecipes(false); // false to not reset pagination
}
};
gridId = 'recipeGrid';
break;
case 'checkpoints':
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
loadMoreCheckpoints(false); // false to not reset
}
};
gridId = 'checkpointGrid';
break;
case 'loras':
default:
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
loadMoreLoras(false); // false to not reset
}
};
gridId = 'loraGrid';
break;
}
const debouncedLoadMore = debounce(loadMoreFunction, 100);
const grid = document.getElementById(gridId);
if (!grid) {
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
console.warn(`Grid with ID "${gridId}" not found for virtual scroll`);
return;
}
// Remove any existing sentinel
const existingSentinel = document.getElementById('scroll-sentinel');
if (existingSentinel) {
existingSentinel.remove();
// Change this line to get the actual scrolling container
const scrollContainer = document.querySelector('.page-content');
const gridContainer = scrollContainer.querySelector('.container');
if (!gridContainer) {
console.warn('Grid container element not found for virtual scroll');
return;
}
// Create a sentinel element after the grid (not inside it)
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
sentinel.style.width = '100%';
sentinel.style.height = '20px';
sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout
try {
// Get the card creator and data fetcher for this page type
const createCardFn = await getCardCreator(pageType);
const fetchDataFn = await getDataFetcher(pageType);
if (!createCardFn || !fetchDataFn) {
throw new Error(`Required components not available for ${pageType} page`);
}
// Initialize virtual scroller with renamed container elements
state.virtualScroller = new VirtualScroller({
gridElement: grid,
containerElement: gridContainer,
scrollContainer: scrollContainer,
createItemFn: createCardFn,
fetchItemsFn: fetchDataFn,
pageSize: 100,
rowGap: 20,
enableDataWindowing: false // Explicitly set to false to disable data windowing
});
// Initialize the virtual scroller
await state.virtualScroller.initialize();
// Add grid class for CSS styling
grid.classList.add('virtual-scroll');
// Setup keyboard navigation
setupKeyboardNavigation();
} catch (error) {
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
// Fallback: show a message in the grid
grid.innerHTML = `
<div class="placeholder-message">
<h3>Failed to initialize ${pageType}</h3>
<p>There was an error loading this page. Please try reloading.</p>
</div>
`;
}
}
// Add keyboard navigation setup function
function setupKeyboardNavigation() {
// Keep track of the last keypress time to prevent multiple rapid triggers
let lastKeyTime = 0;
const keyDelay = 300; // ms between allowed keypresses
// Insert after grid instead of inside
grid.parentNode.insertBefore(sentinel, grid.nextSibling);
// Create observer with appropriate settings, slightly different for checkpoints page
const observerOptions = {
threshold: 0.1,
rootMargin: pageType === 'checkpoints' ? '0px 0px 200px 0px' : '0px 0px 100px 0px'
// Store the event listener reference so we can remove it later if needed
const keyboardNavHandler = (event) => {
// Only handle keyboard events when not in form elements
if (event.target.matches('input, textarea, select')) return;
// Prevent rapid keypresses
const now = Date.now();
if (now - lastKeyTime < keyDelay) return;
lastKeyTime = now;
// Handle navigation keys
if (event.key === 'PageUp') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.handlePageUpDown('up');
}
} else if (event.key === 'PageDown') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.handlePageUpDown('down');
}
} else if (event.key === 'Home') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.scrollToTop();
}
} else if (event.key === 'End') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.scrollToBottom();
}
}
};
// Initialize the observer
state.observer = new IntersectionObserver((entries) => {
const target = entries[0];
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
debouncedLoadMore();
}
}, observerOptions);
// Add the event listener
document.addEventListener('keydown', keyboardNavHandler);
// Start observing
state.observer.observe(sentinel);
// Clean up any existing scroll event listener
if (state.scrollHandler) {
window.removeEventListener('scroll', state.scrollHandler);
state.scrollHandler = null;
// Store the handler in state for potential cleanup
state.keyboardNavHandler = keyboardNavHandler;
}
// Add cleanup function to remove keyboard navigation when needed
export function cleanupKeyboardNavigation() {
if (state.keyboardNavHandler) {
document.removeEventListener('keydown', state.keyboardNavHandler);
state.keyboardNavHandler = null;
}
// Add a simple backup scroll handler
const handleScroll = debounce(() => {
if (pageState.isLoading || !pageState.hasMore) return;
const sentinel = document.getElementById('scroll-sentinel');
if (!sentinel) return;
const rect = sentinel.getBoundingClientRect();
const windowHeight = window.innerHeight;
if (rect.top < windowHeight + 200) {
debouncedLoadMore();
}
}, 200);
state.scrollHandler = handleScroll;
window.addEventListener('scroll', state.scrollHandler);
// Clear any existing interval
if (state.scrollCheckInterval) {
clearInterval(state.scrollCheckInterval);
state.scrollCheckInterval = null;
}
// Export a method to refresh the virtual scroller when filters change
export function refreshVirtualScroll() {
if (state.virtualScroller) {
state.virtualScroller.reset();
state.virtualScroller.initialize();
}
}

View File

@@ -28,8 +28,6 @@ export function showDeleteModal(filePath, modelType = 'lora') {
export async function confirmDelete() {
if (!pendingDeletePath) return;
const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
try {
// Use appropriate delete function based on model type
if (pendingModelType === 'checkpoint') {
@@ -37,10 +35,7 @@ export async function confirmDelete() {
} else {
await deleteLora(pendingDeletePath);
}
if (card) {
card.remove();
}
closeDeleteModal();
} catch (error) {
console.error('Error deleting model:', error);

View File

@@ -351,4 +351,72 @@ export function getNSFWLevelName(level) {
if (level >= 2) return 'PG13';
if (level >= 1) return 'PG';
return 'Unknown';
}
/**
* Sends LoRA syntax to the active ComfyUI workflow
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
* @returns {Promise<boolean>} - Whether the operation was successful
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
try {
// Get the current workflow from localStorage
const workflowData = localStorage.getItem('workflow');
if (!workflowData) {
showToast('No active workflow found', 'error');
return false;
}
// Parse the workflow JSON
const workflow = JSON.parse(workflowData);
// Find all Lora Loader (LoraManager) nodes
const loraNodes = [];
if (workflow.nodes && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.type === "Lora Loader (LoraManager)") {
loraNodes.push(node.id);
}
}
}
if (loraNodes.length === 0) {
showToast('No Lora Loader nodes found in the workflow', 'warning');
return false;
}
// Call the backend API to update the lora code
const response = await fetch('/api/update-lora-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
node_ids: loraNodes,
lora_code: loraSyntax,
mode: replaceMode ? 'replace' : 'append'
})
});
const result = await response.json();
if (result.success) {
// Use different toast messages based on syntax type
if (syntaxType === 'recipe') {
showToast(`Recipe ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
} else {
showToast(`LoRA ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
}
return true;
} else {
showToast(result.error || `Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
} catch (error) {
console.error('Failed to send to workflow:', error);
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
}

View File

@@ -11,6 +11,12 @@
<div class="context-menu-item" data-action="copyname">
<i class="fas fa-copy"></i> Copy LoRA Syntax
</div>
<div class="context-menu-item" data-action="sendappend">
<i class="fas fa-paper-plane"></i> Send to Workflow (Append)
</div>
<div class="context-menu-item" data-action="sendreplace">
<i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)
</div>
<div class="context-menu-item" data-action="preview">
<i class="fas fa-image"></i> Replace Preview
</div>

View File

@@ -47,10 +47,38 @@
</div>
</div>
</div>
<div class="toggle-folders-container">
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
<i class="fas fa-tags"></i>
</button>
<div class="controls-right">
<div class="toggle-folders-container">
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
<i class="fas fa-tags"></i>
</button>
</div>
<div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
Keyboard Navigation:
<table class="keyboard-shortcuts">
<tr>
<td><span class="key">Page Up</span></td>
<td>Scroll up one page</td>
</tr>
<tr>
<td><span class="key">Page Down</span></td>
<td>Scroll down one page</td>
</tr>
<tr>
<td><span class="key">Home</span></td>
<td>Jump to top</td>
</tr>
<tr>
<td><span class="key">End</span></td>
<td>Jump to bottom</td>
</tr>
</table>
</span>
</div>
</div>
</div>
</div>

View File

@@ -154,6 +154,29 @@
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="compactMode">Compact Mode</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="compactMode"
onchange="settingsManager.saveToggleSetting('compactMode', 'compact_mode')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="input-help">
Display more cards per row (7 on 1080p, 8 on 2K, 10 on 4K). <span class="warning-text">Warning: May cause performance issues (lag and lower FPS) on systems with limited resources.</span>
</div>
</div>
</div>
<!-- Add Example Images Settings Section -->
<div class="settings-section">
<h3>Example Images</h3>

View File

@@ -21,6 +21,8 @@
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div>
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe Syntax</div>
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> Send to Workflow (Append)</div>
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</div>
<div class="context-menu-separator"></div>
@@ -77,43 +79,8 @@
<!-- Recipe grid -->
<div class="card-grid" id="recipeGrid">
{% if recipes and recipes|length > 0 %}
{% for recipe in recipes %}
<div class="lora-card" data-file-path="{{ recipe.file_path }}" data-title="{{ recipe.title }}" data-created="{{ recipe.created_date }}">
<div class="recipe-indicator" title="Recipe">R</div>
<div class="card-preview">
<img src="{{ recipe.file_url }}" alt="{{ recipe.title }}">
<div class="card-header">
<div class="base-model-wrapper">
{% if recipe.base_model %}
<span class="base-model-label" title="{{ recipe.base_model }}">
{{ recipe.base_model }}
</span>
{% endif %}
</div>
<div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-copy" title="Copy Recipe"></i>
<i class="fas fa-trash" title="Delete Recipe"></i>
</div>
</div>
<div class="card-footer">
<div class="model-info">
<span class="model-name">{{ recipe.title }}</span>
</div>
<div class="lora-count" title="Number of LoRAs in this recipe">
<i class="fas fa-layer-group"></i> {{ recipe.loras|length }}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="placeholder-message">
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
</div>
{% endif %}
<!-- Remove the server-side conditional rendering and placeholder -->
<!-- Virtual scrolling will handle the display logic on the client side -->
</div>
{% endblock %}

View File

@@ -1,144 +0,0 @@
<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,325 +0,0 @@
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
ICustomWidget,
IWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
import { type Component, toRaw } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<V extends object | string>
extends ICustomWidget {
// ICustomWidget properties
type: 'custom'
options: DOMWidgetOptions<V>
value: V
callback?: (value: V) => void
// BaseDOMWidget properties
/** The unique ID of the widget. */
readonly id: string
/** The node that the widget belongs to. */
readonly node: LGraphNode
/** Whether the widget is visible. */
isVisible(): boolean
/** The margin of the widget. */
margin: number
}
/**
* A DOM widget that wraps a custom HTML element as a litegraph widget.
*/
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends BaseDOMWidget<V> {
element: T
/**
* @deprecated Legacy property used by some extensions for customtext
* (textarea) widgets. Use {@link element} instead as it provides the same
* functionality and works for all DOMWidget types.
*/
inputEl?: T
}
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<V extends object | string>
extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
}
export interface DOMWidgetOptions<V extends object | string>
extends IWidgetOptions {
/**
* Whether to render a placeholder rectangle when zoomed out.
*/
hideOnZoom?: boolean
selectOn?: string[]
onHide?: (widget: BaseDOMWidget<V>) => void
getValue?: () => V
setValue?: (value: V) => void
getMinHeight?: () => number
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: BaseDOMWidget<V>) => void
margin?: number
/**
* @deprecated Use `afterResize` instead. This callback is a legacy API
* that fires before resize happens, but it is no longer supported. Now it
* fires after resize happens.
* The resize logic has been upstreamed to litegraph in
* https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
*/
beforeResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
afterResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
}
export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
widget: IWidget
): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
export const isComponentWidget = <V extends object | string>(
widget: IWidget
): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
abstract class BaseDOMWidgetImpl<V extends object | string>
implements BaseDOMWidget<V>
{
static readonly DEFAULT_MARGIN = 10
readonly type: 'custom'
readonly name: string
readonly options: DOMWidgetOptions<V>
computedHeight?: number
y: number = 0
callback?: (value: V) => void
readonly id: string
readonly node: LGraphNode
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
options: DOMWidgetOptions<V>
}) {
// @ts-expect-error custom widget type
this.type = obj.type
this.name = obj.name
this.options = obj.options
this.id = obj.id
this.node = obj.node
}
get value(): V {
return this.options.getValue?.() ?? ('' as V)
}
set value(v: V) {
this.options.setValue?.(v)
this.callback?.(this.value)
}
get margin(): number {
return this.options.margin ?? BaseDOMWidgetImpl.DEFAULT_MARGIN
}
isVisible(): boolean {
return (
!_.isNil(this.computedHeight) &&
this.computedHeight > 0 &&
!['converted-widget', 'hidden'].includes(this.type) &&
!this.node.collapsed
)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widget_width: number,
y: number,
widget_height: number,
lowQuality?: boolean
): void {
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
// Draw a placeholder rectangle
const originalFillStyle = ctx.fillStyle
ctx.beginPath()
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
ctx.rect(
this.margin,
y + this.margin,
widget_width - this.margin * 2,
(this.computedHeight ?? widget_height) - 2 * this.margin
)
ctx.fill()
ctx.fillStyle = originalFillStyle
}
this.options.onDraw?.(this)
}
onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
extends BaseDOMWidgetImpl<V>
implements DOMWidget<T, V>
{
readonly element: T
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
element: T
options: DOMWidgetOptions<V>
}) {
super(obj)
this.element = obj.element
}
/** Extract DOM widget size info */
computeLayoutSize(node: LGraphNode) {
// @ts-expect-error custom widget type
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
}
}
}
export class ComponentWidgetImpl<V extends object | string>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V>
{
readonly component: Component
readonly inputSpec: InputSpec
constructor(obj: {
id: string
node: LGraphNode
name: string
component: Component
inputSpec: InputSpec
options: DOMWidgetOptions<V>
}) {
super({
...obj,
type: 'custom'
})
this.component = obj.component
this.inputSpec = obj.inputSpec
}
computeLayoutSize() {
const minHeight = this.options.getMinHeight?.() ?? 50
const maxHeight = this.options.getMaxHeight?.()
return {
minHeight,
maxHeight,
minWidth: 0
}
}
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 <
T extends HTMLElement,
V extends object | string
>(
this: LGraphNode,
name: string,
type: string,
element: T,
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
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>)
// 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)
}
})
return widget
}

View File

@@ -1,399 +0,0 @@
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

@@ -1,61 +1,11 @@
import { app } from "../../scripts/app.js";
import { dynamicImportByVersion } from "./utils.js";
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\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 to get connected trigger toggle nodes
function getConnectedTriggerToggleNodes(node) {
const connectedNodes = [];
// Check if node has outputs
if (node.outputs && node.outputs.length > 0) {
// For each output slot
for (const output of node.outputs) {
// Check if this output has any links
if (output.links && output.links.length > 0) {
// For each link, get the target node
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode && targetNode.comfyClass === "TriggerWord Toggle (LoraManager)") {
connectedNodes.push(targetNode.id);
}
}
}
}
}
}
return connectedNodes;
}
// Function to update trigger words for connected toggle nodes
function updateConnectedTriggerWords(node, text) {
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
if (connectedNodeIds.length > 0) {
const loraNames = new Set();
let match;
LORA_PATTERN.lastIndex = 0;
while ((match = LORA_PATTERN.exec(text)) !== null) {
loraNames.add(match[1]);
}
fetch("/loramanager/get_trigger_words", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lora_names: Array.from(loraNames),
node_ids: connectedNodeIds
})
}).catch(err => console.error("Error fetching trigger words:", err));
}
}
import {
getLorasWidgetModule,
LORA_PATTERN,
collectActiveLorasFromChain,
updateConnectedTriggerWords
} from "./utils.js";
import { api } from "../../scripts/api.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
@@ -89,6 +39,45 @@ function mergeLoras(lorasText, lorasArr) {
app.registerExtension({
name: "LoraManager.LoraLoader",
setup() {
// Add message handler to listen for messages from Python
api.addEventListener("lora_code_update", (event) => {
const { id, lora_code, mode } = event.detail;
this.handleLoraCodeUpdate(id, lora_code, mode);
});
},
// Handle lora code updates from Python
handleLoraCodeUpdate(id, loraCode, mode) {
const node = app.graph.getNodeById(+id);
if (!node || node.comfyClass !== "Lora Loader (LoraManager)") {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
// Update the input widget with new lora code
const inputWidget = node.widgets[0];
if (!inputWidget) return;
// Get the current lora code
const currentValue = inputWidget.value || '';
// Update based on mode (replace or append)
if (mode === 'replace') {
inputWidget.value = loraCode;
} else {
// Append mode - add a space if the current value isn't empty
inputWidget.value = currentValue.trim()
? `${currentValue.trim()} ${loraCode}`
: loraCode;
}
// Trigger the callback to update the loras widget
if (typeof inputWidget.callback === 'function') {
inputWidget.callback(inputWidget.value);
}
},
async nodeCreated(node) {
if (node.comfyClass === "Lora Loader (LoraManager)") {
// Enable widget serialization
@@ -107,6 +96,7 @@ app.registerExtension({
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = node.widgets_values[1];
existingLoras = savedValue || [];
}
@@ -124,10 +114,16 @@ app.registerExtension({
const result = addLorasWidget(node, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(node);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(node, allActiveLoraNames);
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = node.widgets[0];
@@ -142,9 +138,6 @@ app.registerExtension({
newText = newText.replace(/\s+/g, ' ').trim();
inputWidget.value = newText;
// Add this line to update trigger words when lorasWidget changes cause inputWidget value to change
updateConnectedTriggerWords(node, newText);
} finally {
isUpdating = false;
}
@@ -163,9 +156,6 @@ app.registerExtension({
const mergedLoras = mergeLoras(value, currentLoras);
node.lorasWidget.value = mergedLoras;
// Replace the existing trigger word update code with the new function
updateConnectedTriggerWords(node, value);
} finally {
isUpdating = false;
}

View File

@@ -1,57 +1,11 @@
import { app } from "../../scripts/app.js";
import { dynamicImportByVersion } from "./utils.js";
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\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 to get connected trigger toggle nodes
function getConnectedTriggerToggleNodes(node) {
const connectedNodes = [];
if (node.outputs && node.outputs.length > 0) {
for (const output of node.outputs) {
if (output.links && output.links.length > 0) {
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode && targetNode.comfyClass === "TriggerWord Toggle (LoraManager)") {
connectedNodes.push(targetNode.id);
}
}
}
}
}
}
return connectedNodes;
}
// Function to update trigger words for connected toggle nodes
function updateConnectedTriggerWords(node, text) {
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
if (connectedNodeIds.length > 0) {
const loraNames = new Set();
let match;
LORA_PATTERN.lastIndex = 0;
while ((match = LORA_PATTERN.exec(text)) !== null) {
loraNames.add(match[1]);
}
fetch("/loramanager/get_trigger_words", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lora_names: Array.from(loraNames),
node_ids: connectedNodeIds
})
}).catch(err => console.error("Error fetching trigger words:", err));
}
}
import {
getLorasWidgetModule,
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords
} from "./utils.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
@@ -99,19 +53,9 @@ app.registerExtension({
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = node.widgets_values[1];
// TODO: clean up this code
try {
// Check if the value is already an array/object
if (typeof savedValue === 'object' && savedValue !== null) {
existingLoras = savedValue;
} else if (typeof savedValue === 'string') {
existingLoras = JSON.parse(savedValue);
}
} catch (e) {
console.warn("Failed to parse loras data:", e);
existingLoras = [];
}
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
@@ -145,8 +89,17 @@ app.registerExtension({
inputWidget.value = newText;
// Update trigger words when lorasWidget changes
updateConnectedTriggerWords(node, newText);
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(node, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(node);
} finally {
isUpdating = false;
}
@@ -166,8 +119,12 @@ app.registerExtension({
node.lorasWidget.value = mergedLoras;
// Update trigger words when input changes
updateConnectedTriggerWords(node, value);
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(node);
updateConnectedTriggerWords(node, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(node);
} finally {
isUpdating = false;
}
@@ -175,4 +132,34 @@ app.registerExtension({
});
}
},
});
});
// Helper function to find and update downstream Lora Loader nodes
function updateDownstreamLoaders(startNode, visited = new Set()) {
if (visited.has(startNode.id)) return;
visited.add(startNode.id);
// Check each output link
if (startNode.outputs) {
for (const output of startNode.outputs) {
if (output.links) {
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
// If target is a Lora Loader, collect all active loras in the chain and update
if (targetNode && targetNode.comfyClass === "Lora Loader (LoraManager)") {
const allActiveLoraNames = collectActiveLorasFromChain(targetNode);
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
}
// If target is another Lora Stacker, recursively check its outputs
else if (targetNode && targetNode.comfyClass === "Lora Stacker (LoraManager)") {
updateDownstreamLoaders(targetNode, visited);
}
}
}
}
}
}
}

View File

@@ -1,5 +1,17 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js";
import {
parseLoraValue,
formatLoraValue,
updateWidgetHeight,
shouldShowClipEntry,
syncClipStrengthIfCollapsed,
LORA_ENTRY_HEIGHT,
HEADER_HEIGHT,
CONTAINER_PADDING,
EMPTY_CONTAINER_HEIGHT
} from "./loras_widget_utils.js";
import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js";
export function addLorasWidget(node, name, opts, callback) {
// Create container for loras
@@ -27,584 +39,9 @@ export function addLorasWidget(node, name, opts, callback) {
// Initialize default value
const defaultValue = opts?.defaultVal || [];
// Fixed sizes for component calculations
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
const CLIP_ENTRY_HEIGHT = 40; // Height of a clip 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
// Remove expandedClipEntries Set since we'll determine expansion based on strength values
// 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 update widget height consistently
const updateWidgetHeight = (height) => {
// Ensure minimum height
const finalHeight = Math.max(defaultHeight, height);
// Update CSS variables
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
// Force node to update size after a short delay to ensure DOM is updated
if (node) {
setTimeout(() => {
node.setDirtyCanvas(true, true);
}, 10);
}
};
// Function to create toggle element
const createToggle = (active, onChange) => {
const toggle = document.createElement("div");
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;
};
// Preview tooltip class
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;
// Add global click event to hide tooltip
document.addEventListener('click', () => this.hide());
// Add scroll event listener
document.addEventListener('scroll', () => this.hide(), true);
}
async show(loraName, x, y) {
try {
// Clear previous hide timer
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) {
return;
}
this.currentLora = loraName;
// Get preview 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');
}
// Clear existing content
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);
// Add fade-in effect
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) {
// Ensure preview box doesn't exceed viewport boundaries
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10; // Default 10px offset to the right of mouse
let top = y + 10; // Default 10px offset below mouse
// Check right boundary
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
// Check bottom boundary
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`
});
}
hide() {
// Use fade-out effect
if (this.element.style.display === 'block') {
this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
// Stop video playback
const video = this.element.querySelector('video');
if (video) {
video.pause();
}
this.hideTimeout = null;
}, 150);
}
}
cleanup() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// Remove all event listeners
document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true);
this.element.remove();
}
}
// Create preview tooltip instance
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 handle strength adjustment via dragging
const handleStrengthDrag = (name, initialStrength, initialX, event, widget, isClipStrength = false) => {
// Calculate drag sensitivity (how much the strength changes per pixel)
// Using 0.01 per 10 pixels of movement
const sensitivity = 0.001;
// Get the current mouse position
const currentX = event.clientX;
// Calculate the distance moved
const deltaX = currentX - initialX;
// Calculate the new strength value based on movement
// Moving right increases, moving left decreases
let newStrength = Number(initialStrength) + (deltaX * sensitivity);
// Limit the strength to reasonable bounds (now between -10 and 10)
newStrength = Math.max(-10, Math.min(10, newStrength));
newStrength = Number(newStrength.toFixed(2));
// Update the lora data
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
// Update the appropriate strength property based on isClipStrength flag
if (isClipStrength) {
lorasData[loraIndex].clipStrength = newStrength;
} else {
lorasData[loraIndex].strength = newStrength;
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
// Force re-render to show updated strength value
renderLoras(widget.value, widget);
}
};
// Function to initialize drag operation
const initDrag = (dragEl, name, widget, isClipStrength = false) => {
let isDragging = false;
let initialX = 0;
let initialStrength = 0;
// Create a style element for drag cursor override if it doesn't exist
if (!document.getElementById('comfy-lora-drag-style')) {
const styleEl = document.createElement('style');
styleEl.id = 'comfy-lora-drag-style';
styleEl.textContent = `
body.comfy-lora-dragging,
body.comfy-lora-dragging * {
cursor: ew-resize !important;
}
`;
document.head.appendChild(styleEl);
}
// Create a drag handler
dragEl.addEventListener('mousedown', (e) => {
// Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow')) {
return;
}
// Store initial values
const lorasData = parseLoraValue(widget.value);
const loraData = lorasData.find(l => l.name === name);
if (!loraData) return;
initialX = e.clientX;
initialStrength = isClipStrength ? loraData.clipStrength : loraData.strength;
isDragging = true;
// Add class to body to enforce cursor style globally
document.body.classList.add('comfy-lora-dragging');
// Prevent text selection during drag
e.preventDefault();
});
// Use the document for move and up events to ensure drag continues
// even if mouse leaves the element
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
// Call the strength adjustment function
handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength);
// Prevent showing the preview tooltip during drag
previewTooltip.hide();
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('comfy-lora-dragging');
}
});
};
// 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);
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
@@ -633,7 +70,7 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
updateWidgetHeight(container, EMPTY_CONTAINER_HEIGHT, defaultHeight, node);
return;
}
@@ -646,7 +83,8 @@ export function addLorasWidget(node, name, opts, callback) {
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
marginBottom: "5px"
marginBottom: "5px",
position: "relative" // Added for positioning the drag hint
});
// Add toggle all control
@@ -681,7 +119,7 @@ export function addLorasWidget(node, name, opts, callback) {
toggleContainer.appendChild(toggleAll);
toggleContainer.appendChild(toggleLabel);
// Strength label
// Strength label with drag hint
const strengthLabel = document.createElement("div");
strengthLabel.textContent = "Strength";
Object.assign(strengthLabel.style, {
@@ -692,11 +130,36 @@ export function addLorasWidget(node, name, opts, callback) {
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
display: "flex",
alignItems: "center"
});
// Add drag hint icon next to strength label
const dragHint = document.createElement("span");
dragHint.innerHTML = "↔"; // Simple left-right arrow as drag indicator
Object.assign(dragHint.style, {
marginLeft: "5px",
fontSize: "11px",
opacity: "0.6",
transition: "opacity 0.2s ease"
});
strengthLabel.appendChild(dragHint);
// Add hover effect to improve discoverability
header.addEventListener("mouseenter", () => {
dragHint.style.opacity = "1";
});
header.addEventListener("mouseleave", () => {
dragHint.style.opacity = "0.6";
});
header.appendChild(toggleContainer);
header.appendChild(strengthLabel);
container.appendChild(header);
// Initialize the header drag functionality
initHeaderDrag(header, widget, renderLoras);
// Track the total visible entries for height calculation
let totalVisibleEntries = lorasData.length;
@@ -810,7 +273,7 @@ export function addLorasWidget(node, name, opts, callback) {
});
// Initialize drag functionality for strength adjustment
initDrag(loraEl, name, widget, false);
initDrag(loraEl, name, widget, false, previewTooltip, renderLoras);
// Remove the preview tooltip events from loraEl
loraEl.onmouseenter = () => {
@@ -825,7 +288,7 @@ export function addLorasWidget(node, name, opts, callback) {
loraEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
createContextMenu(e.clientX, e.clientY, name, widget);
createContextMenu(e.clientX, e.clientY, name, widget, previewTooltip, renderLoras);
});
// Create strength control
@@ -844,6 +307,8 @@ export function addLorasWidget(node, name, opts, callback) {
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
@@ -906,6 +371,8 @@ export function addLorasWidget(node, name, opts, callback) {
if (loraIndex >= 0) {
lorasData[loraIndex].strength = newValue.toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
// Update value and trigger callback
const newLorasValue = formatLoraValue(lorasData);
@@ -928,6 +395,8 @@ export function addLorasWidget(node, name, opts, callback) {
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
const newValue = formatLoraValue(lorasData);
widget.value = newValue;
@@ -1125,7 +594,7 @@ export function addLorasWidget(node, name, opts, callback) {
};
// Add drag functionality to clip entry
initDrag(clipEl, name, widget, true);
initDrag(clipEl, name, widget, true, previewTooltip, renderLoras);
container.appendChild(clipEl);
}
@@ -1133,7 +602,7 @@ export function addLorasWidget(node, name, opts, callback) {
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 8) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(calculatedHeight);
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
};
// Store the value in a variable to avoid recursion
@@ -1219,69 +688,4 @@ export function addLorasWidget(node, name, opts, callback) {
};
return { minWidth: 400, minHeight: defaultHeight, widget };
}
// Function to directly save the recipe without dialog
async function saveRecipeDirectly(widget) {
try {
const prompt = await app.graphToPrompt();
console.log(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
});
}
// Send the request to the backend API without workflow data
const response = await fetch('/api/recipes/save-from-widget', {
method: 'POST'
});
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
});
}
}
}
// Determine if clip entry should be shown - now based on expanded property or initial diff values
const shouldShowClipEntry = (loraData) => {
// If expanded property exists, use that
if (loraData.hasOwnProperty('expanded')) {
return loraData.expanded;
}
// Otherwise use the legacy logic - if values differ, it should be expanded
return Number(loraData.strength) !== Number(loraData.clipStrength);
}

View File

@@ -0,0 +1,303 @@
import { api } from "../../scripts/api.js";
// Function to create toggle element
export function 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
export 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
export function 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;
}
// Function to create menu item
export function 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;
}
// Preview tooltip class
export 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;
// Add global click event to hide tooltip
document.addEventListener('click', () => this.hide());
// Add scroll event listener
document.addEventListener('scroll', () => this.hide(), true);
}
async show(loraName, x, y) {
try {
// Clear previous hide timer
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) {
return;
}
this.currentLora = loraName;
// Get preview 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');
}
// Clear existing content
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);
// Add fade-in effect
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) {
// Ensure preview box doesn't exceed viewport boundaries
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10; // Default 10px offset to the right of mouse
let top = y + 10; // Default 10px offset below mouse
// Check right boundary
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
// Check bottom boundary
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`
});
}
hide() {
// Use fade-out effect
if (this.element.style.display === 'block') {
this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
// Stop video playback
const video = this.element.querySelector('video');
if (video) {
video.pause();
}
this.hideTimeout = null;
}, 150);
}
}
cleanup() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// Remove all event listeners
document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true);
this.element.remove();
}
}

View File

@@ -0,0 +1,433 @@
import { api } from "../../scripts/api.js";
import { createMenuItem } from "./loras_widget_components.js";
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast } from "./loras_widget_utils.js";
// Function to handle strength adjustment via dragging
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) {
// Calculate drag sensitivity (how much the strength changes per pixel)
// Using 0.01 per 10 pixels of movement
const sensitivity = 0.001;
// Get the current mouse position
const currentX = event.clientX;
// Calculate the distance moved
const deltaX = currentX - initialX;
// Calculate the new strength value based on movement
// Moving right increases, moving left decreases
let newStrength = Number(initialStrength) + (deltaX * sensitivity);
// Limit the strength to reasonable bounds (now between -10 and 10)
newStrength = Math.max(-10, Math.min(10, newStrength));
newStrength = Number(newStrength.toFixed(2));
// Update the lora data
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
// Update the appropriate strength property based on isClipStrength flag
if (isClipStrength) {
lorasData[loraIndex].clipStrength = newStrength;
} else {
lorasData[loraIndex].strength = newStrength;
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
// Force re-render via callback
if (widget.callback) {
widget.callback(widget.value);
}
}
}
// Function to handle proportional strength adjustment for all LoRAs via header dragging
export function handleAllStrengthsDrag(initialStrengths, initialX, event, widget) {
// Define sensitivity (less sensitive than individual adjustment)
const sensitivity = 0.0005;
// Get current mouse position
const currentX = event.clientX;
// Calculate the distance moved
const deltaX = currentX - initialX;
// Calculate adjustment factor (1.0 means no change, >1.0 means increase, <1.0 means decrease)
// For positive deltaX, we want to increase strengths, for negative we want to decrease
const adjustmentFactor = 1.0 + (deltaX * sensitivity);
// Ensure adjustment factor is reasonable (prevent extreme changes)
const limitedFactor = Math.max(0.01, Math.min(3.0, adjustmentFactor));
// Get current loras data
const lorasData = parseLoraValue(widget.value);
// Apply the adjustment factor to each LoRA's strengths
lorasData.forEach((loraData, index) => {
// Get initial strengths for this LoRA
const initialModelStrength = initialStrengths[index].modelStrength;
const initialClipStrength = initialStrengths[index].clipStrength;
// Apply the adjustment factor to both strengths
let newModelStrength = (initialModelStrength * limitedFactor).toFixed(2);
let newClipStrength = (initialClipStrength * limitedFactor).toFixed(2);
// Limit the values to reasonable bounds (-10 to 10)
newModelStrength = Math.max(-10, Math.min(10, newModelStrength));
newClipStrength = Math.max(-10, Math.min(10, newClipStrength));
// Update strengths
lorasData[index].strength = Number(newModelStrength);
lorasData[index].clipStrength = Number(newClipStrength);
});
// Update widget value
widget.value = formatLoraValue(lorasData);
// Force re-render via callback
if (widget.callback) {
widget.callback(widget.value);
}
}
// Function to initialize drag operation
export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) {
let isDragging = false;
let initialX = 0;
let initialStrength = 0;
// Create a style element for drag cursor override if it doesn't exist
if (!document.getElementById('comfy-lora-drag-style')) {
const styleEl = document.createElement('style');
styleEl.id = 'comfy-lora-drag-style';
styleEl.textContent = `
body.comfy-lora-dragging,
body.comfy-lora-dragging * {
cursor: ew-resize !important;
}
`;
document.head.appendChild(styleEl);
}
// Create a drag handler
dragEl.addEventListener('mousedown', (e) => {
// Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow')) {
return;
}
// Store initial values
const lorasData = parseLoraValue(widget.value);
const loraData = lorasData.find(l => l.name === name);
if (!loraData) return;
initialX = e.clientX;
initialStrength = isClipStrength ? loraData.clipStrength : loraData.strength;
isDragging = true;
// Add class to body to enforce cursor style globally
document.body.classList.add('comfy-lora-dragging');
// Prevent text selection during drag
e.preventDefault();
});
// Use the document for move and up events to ensure drag continues
// even if mouse leaves the element
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
// Call the strength adjustment function
handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength);
// Force re-render to show updated strength value
if (renderFunction) {
renderFunction(widget.value, widget);
}
// Prevent showing the preview tooltip during drag
if (previewTooltip) {
previewTooltip.hide();
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('comfy-lora-dragging');
}
});
}
// Function to initialize header drag for proportional strength adjustment
export function initHeaderDrag(headerEl, widget, renderFunction) {
let isDragging = false;
let initialX = 0;
let initialStrengths = [];
// Add cursor style to indicate draggable
headerEl.style.cursor = 'ew-resize';
// Create a drag handler
headerEl.addEventListener('mousedown', (e) => {
// Skip if clicking on toggle or other interactive elements
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input')) {
return;
}
// Store initial X position
initialX = e.clientX;
// Store initial strengths of all LoRAs
const lorasData = parseLoraValue(widget.value);
initialStrengths = lorasData.map(lora => ({
modelStrength: Number(lora.strength),
clipStrength: Number(lora.clipStrength)
}));
isDragging = true;
// Add class to body to enforce cursor style globally
document.body.classList.add('comfy-lora-dragging');
// Prevent text selection during drag
e.preventDefault();
});
// Handle mouse move for dragging
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
// Call the strength adjustment function
handleAllStrengthsDrag(initialStrengths, initialX, e, widget);
// Force re-render to show updated strength values
if (renderFunction) {
renderFunction(widget.value, widget);
}
});
// Handle mouse up to end dragging
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('comfy-lora-dragging');
}
});
}
// Function to create context menu
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
// Hide preview tooltip first
if (previewTooltip) {
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
showToast('This LoRA has no associated Civitai URL', 'warning');
}
} catch (error) {
console.error('Error getting Civitai URL:', error);
showToast(error.message || 'Failed to get Civitai URL', 'error');
}
}
);
// 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);
}
// Re-render
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
);
// New option: Copy Notes with note icon
const copyNotesOption = createMenuItem(
'Copy Notes',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>',
async () => {
menu.remove();
document.removeEventListener('click', closeMenu);
try {
// Get notes from API
const response = await api.fetchApi(`/loras/get-notes?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to get notes');
}
const data = await response.json();
if (data.success) {
const notes = data.notes || '';
if (notes.trim()) {
await copyToClipboard(notes, 'Notes copied to clipboard');
} else {
showToast('No notes available for this LoRA', 'info');
}
} else {
throw new Error(data.error || 'Failed to get notes');
}
} catch (error) {
console.error('Error getting notes:', error);
showToast(error.message || 'Failed to get notes', 'error');
}
}
);
// New option: Copy Trigger Words with tag icon
const copyTriggerWordsOption = createMenuItem(
'Copy Trigger Words',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7.01" y2="7"></line></svg>',
async () => {
menu.remove();
document.removeEventListener('click', closeMenu);
try {
// Get trigger words from API
const response = await api.fetchApi(`/loras/get-trigger-words?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to get trigger words');
}
const data = await response.json();
if (data.success) {
const triggerWords = data.trigger_words || [];
if (triggerWords.length > 0) {
// Join trigger words with commas
const triggerWordsText = triggerWords.join(', ');
await copyToClipboard(triggerWordsText, 'Trigger words copied to clipboard');
} else {
showToast('No trigger words available for this LoRA', 'info');
}
} else {
throw new Error(data.error || 'Failed to get trigger words');
}
} catch (error) {
console.error('Error getting trigger words:', error);
showToast(error.message || 'Failed to get trigger words', 'error');
}
}
);
// 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();
}
);
// Add separator
const separator1 = document.createElement('div');
Object.assign(separator1.style, {
margin: '4px 0',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
});
// Add second separator
const separator2 = document.createElement('div');
Object.assign(separator2.style, {
margin: '4px 0',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
});
menu.appendChild(viewOnCivitaiOption);
menu.appendChild(deleteOption);
menu.appendChild(separator1);
menu.appendChild(copyNotesOption);
menu.appendChild(copyTriggerWordsOption);
menu.appendChild(separator2);
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);
}

View File

@@ -0,0 +1,166 @@
import { app } from "../../scripts/app.js";
// Fixed sizes for component calculations
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
export const HEADER_HEIGHT = 40; // Height of the header section
export const CONTAINER_PADDING = 12; // Top and bottom padding
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
// Parse LoRA entries from value
export function parseLoraValue(value) {
if (!value) return [];
return Array.isArray(value) ? value : [];
}
// Format LoRA data
export function formatLoraValue(loras) {
return loras;
}
// Function to update widget height consistently
export function updateWidgetHeight(container, height, defaultHeight, node) {
// 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);
}
}
// Determine if clip entry should be shown - now based on expanded property or initial diff values
export function shouldShowClipEntry(loraData) {
// If expanded property exists, use that
if (loraData.hasOwnProperty('expanded')) {
return loraData.expanded;
}
// Otherwise use the legacy logic - if values differ, it should be expanded
return Number(loraData.strength) !== Number(loraData.clipStrength);
}
// Helper function to sync clipStrength with strength when collapsed
export function syncClipStrengthIfCollapsed(loraData) {
// If not expanded (collapsed), sync clipStrength with strength
if (loraData.hasOwnProperty('expanded') && !loraData.expanded) {
loraData.clipStrength = loraData.strength;
}
return loraData;
}
// Function to directly save the recipe without dialog
export async function saveRecipeDirectly() {
try {
const prompt = await app.graphToPrompt();
console.log('Prompt:', prompt); // for debugging purposes
// Show loading toast
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: 'info',
summary: 'Saving Recipe',
detail: 'Please wait...',
life: 2000
});
}
// Send the request to the backend API
const response = await fetch('/api/recipes/save-from-widget', {
method: 'POST'
});
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
});
}
}
}
/**
* Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard
* @param {string} successMessage - Optional success message to show in toast
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
*/
export async function copyToClipboard(text, successMessage = 'Copied to clipboard') {
try {
// Modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-99999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
if (successMessage) {
showToast(successMessage, 'success');
}
return true;
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
return false;
}
}
/**
* Show a toast notification
* @param {string} message - The message to display
* @param {string} type - The type of toast (success, error, info, warning)
*/
export function showToast(message, type = 'info') {
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add({
severity: type,
summary: type.charAt(0).toUpperCase() + type.slice(1),
detail: message,
life: 3000
});
} else {
console.log(`${type.toUpperCase()}: ${message}`);
// Fallback alert for critical errors only
if (type === 'error') {
alert(message);
}
}
}

View File

@@ -63,4 +63,106 @@ export class DataWrapper {
setData(data) {
this.data = data;
}
}
// Function to get the appropriate loras widget based on ComfyUI version
export async function getLorasWidgetModule() {
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
}
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
export const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
// Get connected Lora Stacker nodes that feed into the current node
export function getConnectedInputStackers(node) {
const connectedStackers = [];
if (node.inputs) {
for (const input of node.inputs) {
if (input.name === "lora_stack" && input.link) {
const link = app.graph.links[input.link];
if (link) {
const sourceNode = app.graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.comfyClass === "Lora Stacker (LoraManager)") {
connectedStackers.push(sourceNode);
}
}
}
}
}
return connectedStackers;
}
// Get connected TriggerWord Toggle nodes that receive output from the current node
export function getConnectedTriggerToggleNodes(node) {
const connectedNodes = [];
if (node.outputs && node.outputs.length > 0) {
for (const output of node.outputs) {
if (output.links && output.links.length > 0) {
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode && targetNode.comfyClass === "TriggerWord Toggle (LoraManager)") {
connectedNodes.push(targetNode.id);
}
}
}
}
}
}
return connectedNodes;
}
// Extract active lora names from a node's widgets
export function getActiveLorasFromNode(node) {
const activeLoraNames = new Set();
// For lorasWidget style entries (array of objects)
if (node.lorasWidget && node.lorasWidget.value) {
node.lorasWidget.value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
}
return activeLoraNames;
}
// Recursively collect all active loras from a node and its input chain
export function collectActiveLorasFromChain(node, visited = new Set()) {
// Prevent infinite loops from circular references
if (visited.has(node.id)) {
return new Set();
}
visited.add(node.id);
// Get active loras from current node
const allActiveLoraNames = getActiveLorasFromNode(node);
// Get connected input stackers and collect their active loras
const inputStackers = getConnectedInputStackers(node);
for (const stacker of inputStackers) {
const stackerLoras = collectActiveLorasFromChain(stacker, visited);
stackerLoras.forEach(name => allActiveLoraNames.add(name));
}
return allActiveLoraNames;
}
// Update trigger words for connected toggle nodes
export function updateConnectedTriggerWords(node, loraNames) {
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
if (connectedNodeIds.length > 0) {
fetch("/loramanager/get_trigger_words", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lora_names: Array.from(loraNames),
node_ids: connectedNodeIds
})
}).catch(err => console.error("Error fetching trigger words:", err));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
wiki-images/recipe-save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB