mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4b5a4d45 | ||
|
|
78e1901779 | ||
|
|
cb539314de | ||
|
|
c7627fe0de | ||
|
|
84bfad7ce5 | ||
|
|
3e06938b05 | ||
|
|
4f712fec14 | ||
|
|
c5c9659c76 | ||
|
|
d6e175c1f1 | ||
|
|
88088e1071 | ||
|
|
958ddbca86 | ||
|
|
6670fd28f4 | ||
|
|
1e59c31de3 | ||
|
|
c966dbbbbc | ||
|
|
af8f5ba04e | ||
|
|
b741ed0b3b | ||
|
|
01ba3c14f8 | ||
|
|
d13b1a83ad | ||
|
|
303477db70 | ||
|
|
311e89e9e7 | ||
|
|
8546cfe714 | ||
|
|
e6f4d84b9a | ||
|
|
ce7e422169 | ||
|
|
e5aec80984 | ||
|
|
6d97817390 | ||
|
|
d516f22159 | ||
|
|
e918c18ca2 | ||
|
|
5dd8d905fa | ||
|
|
1121d1ee6c | ||
|
|
4793f096af | ||
|
|
7b5b4ce082 |
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
ko_fi: pixelpawsai
|
||||||
|
custom: ['paypal.me/pixelpawsai']
|
||||||
@@ -20,6 +20,11 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.8.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
|
### v0.8.13
|
||||||
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
* **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
|
* **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
|
||||||
|
|||||||
@@ -187,19 +187,36 @@ class MetadataProcessor:
|
|||||||
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
||||||
params["sampler"] = sampler_params.get("sampler_name")
|
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)
|
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
|
||||||
if guider_node_id:
|
if guider_node_id and guider_node_id in prompt.original_prompt:
|
||||||
# Look for FluxGuidance along the guider path
|
# Check if the guider node is a CFGGuider
|
||||||
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
|
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
|
||||||
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
|
# Extract cfg value from the CFGGuider
|
||||||
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
|
if guider_node_id in metadata.get(SAMPLING, {}):
|
||||||
params["guidance"] = flux_params.get("guidance")
|
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
|
||||||
|
params["cfg_scale"] = cfg_params.get("cfg")
|
||||||
# Find CLIPTextEncode for positive prompt (through conditioning)
|
|
||||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
|
# Find CLIPTextEncode for positive prompt
|
||||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
|
||||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
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:
|
else:
|
||||||
# Original tracing for standard samplers
|
# Original tracing for standard samplers
|
||||||
|
|||||||
@@ -362,6 +362,23 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
|||||||
|
|
||||||
metadata[SAMPLING][node_id]["parameters"]["guidance"] = guidance_value
|
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
|
# Registry of node-specific extractors
|
||||||
NODE_EXTRACTORS = {
|
NODE_EXTRACTORS = {
|
||||||
# Sampling
|
# Sampling
|
||||||
@@ -383,6 +400,7 @@ NODE_EXTRACTORS = {
|
|||||||
"EmptyLatentImage": ImageSizeExtractor,
|
"EmptyLatentImage": ImageSizeExtractor,
|
||||||
# Flux
|
# Flux
|
||||||
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
||||||
|
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||||
# Image
|
# Image
|
||||||
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
||||||
# Add other nodes as needed
|
# Add other nodes as needed
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class ApiRoutes:
|
|||||||
|
|
||||||
# Add new endpoint for letter counts
|
# Add new endpoint for letter counts
|
||||||
app.router.add_get('/api/loras/letter-counts', routes.get_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
|
# Add update check routes
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
@@ -1084,3 +1088,81 @@ class ApiRoutes:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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)
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ class CheckpointsRoutes:
|
|||||||
"""Get detailed information for a specific checkpoint by name"""
|
"""Get detailed information for a specific checkpoint by name"""
|
||||||
try:
|
try:
|
||||||
name = request.match_info.get('name', '')
|
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:
|
if checkpoint_info:
|
||||||
return web.json_response(checkpoint_info)
|
return web.json_response(checkpoint_info)
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class MiscRoutes:
|
|||||||
output_dir = data.get('output_dir')
|
output_dir = data.get('output_dir')
|
||||||
optimize = data.get('optimize', True)
|
optimize = data.get('optimize', True)
|
||||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
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:
|
if not output_dir:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||||
version = "0.8.13"
|
version = "0.8.14"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/* 卡片网格布局 */
|
/* 卡片网格布局 */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||||
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
|
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||||
|
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||||
max-width: 1400px; /* Container width control */
|
max-width: 1400px; /* Base container width */
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
@@ -17,13 +18,14 @@
|
|||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
aspect-ratio: 896/1152;
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
max-width: 260px; /* Base size */
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer; /* Added from recipe-card */
|
cursor: pointer;
|
||||||
display: flex; /* Added from recipe-card */
|
display: flex;
|
||||||
flex-direction: column; /* Added from recipe-card */
|
flex-direction: column;
|
||||||
overflow: hidden; /* Add overflow hidden to contain children */
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lora-card:hover {
|
.lora-card:hover {
|
||||||
@@ -36,6 +38,30 @@
|
|||||||
outline-offset: 2px;
|
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 */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 1400px) {
|
@media (max-width: 1400px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
@@ -58,6 +84,26 @@
|
|||||||
min-height: 0; /* Fix for potential flexbox sizing issue in Firefox */
|
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 img,
|
||||||
.card-preview video {
|
.card-preview video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -362,4 +408,42 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--lora-surface-alt);
|
background: var(--lora-surface-alt);
|
||||||
border-radius: var(--border-radius-base);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -672,4 +672,14 @@ input:checked + .toggle-slider:before {
|
|||||||
|
|
||||||
.changelog-item a:hover {
|
.changelog-item a:hover {
|
||||||
text-decoration: underline;
|
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);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,19 @@
|
|||||||
z-index: var(--z-base);
|
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 {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
|
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js';
|
|
||||||
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.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
|
// Update folder tags in the UI
|
||||||
export function updateFolderTags(folders) {
|
export function updateFolderTags(folders) {
|
||||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
const folderTagsContainer = document.querySelector('.folder-tags');
|
||||||
@@ -231,10 +455,15 @@ export async function deleteModel(filePath, modelType = 'lora') {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Remove the card from UI
|
// If virtual scroller exists, update its data
|
||||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
if (state.virtualScroller) {
|
||||||
if (card) {
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
card.remove();
|
} 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');
|
showToast(`${modelType} deleted successfully`, 'success');
|
||||||
@@ -283,7 +512,7 @@ export async function refreshModels(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof resetAndReloadFunction === 'function') {
|
if (typeof resetAndReloadFunction === 'function') {
|
||||||
await resetAndReloadFunction();
|
await resetAndReloadFunction(true); // update folders
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Refresh complete`, 'success');
|
showToast(`Refresh complete`, 'success');
|
||||||
@@ -449,10 +678,15 @@ export async function excludeModel(filePath, modelType = 'lora') {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Remove the card from UI
|
// If virtual scroller exists, update its data
|
||||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
if (state.virtualScroller) {
|
||||||
if (card) {
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
card.remove();
|
} 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');
|
showToast(`${modelType} excluded successfully`, 'success');
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
||||||
import {
|
import {
|
||||||
loadMoreModels,
|
loadMoreModels,
|
||||||
|
fetchModelsPage,
|
||||||
resetAndReload as baseResetAndReload,
|
resetAndReload as baseResetAndReload,
|
||||||
|
resetAndReloadWithVirtualScroll,
|
||||||
|
loadMoreWithVirtualScroll,
|
||||||
refreshModels as baseRefreshModels,
|
refreshModels as baseRefreshModels,
|
||||||
deleteModel as baseDeleteModel,
|
deleteModel as baseDeleteModel,
|
||||||
replaceModelPreview,
|
replaceModelPreview,
|
||||||
@@ -9,25 +12,67 @@ import {
|
|||||||
refreshSingleModelMetadata,
|
refreshSingleModelMetadata,
|
||||||
excludeModel as baseExcludeModel
|
excludeModel as baseExcludeModel
|
||||||
} from './baseModelApi.js';
|
} from './baseModelApi.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
// Load more checkpoints with pagination
|
/**
|
||||||
export async function loadMoreCheckpoints(resetPagination = true) {
|
* Fetch checkpoints with pagination for virtual scrolling
|
||||||
return loadMoreModels({
|
* @param {number} page - Page number to fetch
|
||||||
resetPage: resetPagination,
|
* @param {number} pageSize - Number of items per page
|
||||||
updateFolders: true,
|
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||||
|
*/
|
||||||
|
export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
|
||||||
|
return fetchModelsPage({
|
||||||
modelType: 'checkpoint',
|
modelType: 'checkpoint',
|
||||||
createCardFunction: createCheckpointCard,
|
page,
|
||||||
|
pageSize,
|
||||||
endpoint: '/api/checkpoints'
|
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
|
// Reset and reload checkpoints
|
||||||
export async function resetAndReload() {
|
export async function resetAndReload(updateFolders = false) {
|
||||||
return baseResetAndReload({
|
// Check if virtual scroller is available
|
||||||
updateFolders: true,
|
if (state.virtualScroller) {
|
||||||
modelType: 'checkpoint',
|
return resetAndReloadWithVirtualScroll({
|
||||||
loadMoreFunction: loadMoreCheckpoints
|
modelType: 'checkpoint',
|
||||||
});
|
updateFolders,
|
||||||
|
fetchPageFunction: fetchCheckpointsPage
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to original implementation
|
||||||
|
return baseResetAndReload({
|
||||||
|
updateFolders,
|
||||||
|
modelType: 'checkpoint',
|
||||||
|
loadMoreFunction: loadMoreCheckpoints
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh checkpoints
|
// Refresh checkpoints
|
||||||
@@ -60,7 +105,11 @@ export async function fetchCivitai() {
|
|||||||
|
|
||||||
// Refresh single checkpoint metadata
|
// Refresh single checkpoint metadata
|
||||||
export async function refreshSingleCheckpointMetadata(filePath) {
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createLoraCard } from '../components/LoraCard.js';
|
import { createLoraCard } from '../components/LoraCard.js';
|
||||||
import {
|
import {
|
||||||
loadMoreModels,
|
loadMoreModels,
|
||||||
|
fetchModelsPage,
|
||||||
resetAndReload as baseResetAndReload,
|
resetAndReload as baseResetAndReload,
|
||||||
|
resetAndReloadWithVirtualScroll,
|
||||||
|
loadMoreWithVirtualScroll,
|
||||||
refreshModels as baseRefreshModels,
|
refreshModels as baseRefreshModels,
|
||||||
deleteModel as baseDeleteModel,
|
deleteModel as baseDeleteModel,
|
||||||
replaceModelPreview,
|
replaceModelPreview,
|
||||||
@@ -9,6 +12,7 @@ import {
|
|||||||
refreshSingleModelMetadata,
|
refreshSingleModelMetadata,
|
||||||
excludeModel as baseExcludeModel
|
excludeModel as baseExcludeModel
|
||||||
} from './baseModelApi.js';
|
} from './baseModelApi.js';
|
||||||
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save model metadata to the server
|
* Save model metadata to the server
|
||||||
@@ -44,12 +48,46 @@ export async function excludeLora(filePath) {
|
|||||||
return baseExcludeModel(filePath, 'lora');
|
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) {
|
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||||
return loadMoreModels({
|
const pageState = getCurrentPageState();
|
||||||
resetPage,
|
|
||||||
updateFolders,
|
// 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',
|
modelType: 'lora',
|
||||||
createCardFunction: createLoraCard,
|
page,
|
||||||
|
pageSize,
|
||||||
endpoint: '/api/loras'
|
endpoint: '/api/loras'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,21 +109,38 @@ export async function replacePreview(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function appendLoraCards(loras) {
|
export function appendLoraCards(loras) {
|
||||||
const grid = document.getElementById('loraGrid');
|
// This function is no longer needed with virtual scrolling
|
||||||
const sentinel = document.getElementById('scroll-sentinel');
|
// but kept for compatibility
|
||||||
|
if (state.virtualScroller) {
|
||||||
loras.forEach(lora => {
|
console.warn('appendLoraCards is deprecated when using virtual scrolling');
|
||||||
const card = createLoraCard(lora);
|
} else {
|
||||||
grid.appendChild(card);
|
const grid = document.getElementById('loraGrid');
|
||||||
});
|
|
||||||
|
loras.forEach(lora => {
|
||||||
|
const card = createLoraCard(lora);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAndReload(updateFolders = false) {
|
export async function resetAndReload(updateFolders = false) {
|
||||||
return baseResetAndReload({
|
const pageState = getCurrentPageState();
|
||||||
updateFolders,
|
|
||||||
modelType: 'lora',
|
// Check if virtual scroller is available
|
||||||
loadMoreFunction: loadMoreLoras
|
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() {
|
export async function refreshLoras() {
|
||||||
|
|||||||
174
static/js/api/recipeApi.js
Normal file
174
static/js/api/recipeApi.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
||||||
@@ -40,9 +39,6 @@ class CheckpointsPageManager {
|
|||||||
// Initialize context menu
|
// Initialize context menu
|
||||||
new CheckpointContextMenu();
|
new CheckpointContextMenu();
|
||||||
|
|
||||||
// Initialize infinite scroll
|
|
||||||
initializeInfiniteScroll('checkpoints');
|
|
||||||
|
|
||||||
// Initialize common page features
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Duplicates Manager Component
|
// Duplicates Manager Component
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.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';
|
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||||
|
|
||||||
export class DuplicatesManager {
|
export class DuplicatesManager {
|
||||||
@@ -61,10 +61,9 @@ export class DuplicatesManager {
|
|||||||
banner.style.display = 'block';
|
banner.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable infinite scroll
|
// Disable virtual scrolling if active
|
||||||
if (this.recipeManager.observer) {
|
if (state.virtualScroller) {
|
||||||
this.recipeManager.observer.disconnect();
|
state.virtualScroller.disable();
|
||||||
this.recipeManager.observer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add duplicate-mode class to the body
|
// Add duplicate-mode class to the body
|
||||||
@@ -94,13 +93,21 @@ export class DuplicatesManager {
|
|||||||
// Remove duplicate-mode class from the body
|
// Remove duplicate-mode class from the body
|
||||||
document.body.classList.remove('duplicate-mode');
|
document.body.classList.remove('duplicate-mode');
|
||||||
|
|
||||||
// Reload normal recipes view
|
// Clear the recipe grid first
|
||||||
this.recipeManager.loadRecipes();
|
const recipeGrid = document.getElementById('recipeGrid');
|
||||||
|
if (recipeGrid) {
|
||||||
|
recipeGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Reinitialize infinite scroll
|
// Re-enable virtual scrolling
|
||||||
setTimeout(() => {
|
if (state.virtualScroller) {
|
||||||
initializeInfiniteScroll('recipes');
|
state.virtualScroller.enable();
|
||||||
}, 500);
|
} else {
|
||||||
|
// If virtual scroller doesn't exist, reinitialize it
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeInfiniteScroll('recipes');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDuplicateGroups() {
|
renderDuplicateGroups() {
|
||||||
|
|||||||
@@ -6,6 +6,181 @@ import { NSFW_LEVELS } from '../utils/constants.js';
|
|||||||
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
|
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
|
||||||
import { showDeleteModal } from '../utils/modalUtils.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-copy')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
copyLoraCode(card);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLoraCode(card) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
export function createLoraCard(lora) {
|
export function createLoraCard(lora) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'lora-card';
|
card.className = 'lora-card';
|
||||||
@@ -123,162 +298,12 @@ export function createLoraCard(lora) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Main card click event - modified to handle bulk mode
|
// Add a special class for virtual scroll positioning if needed
|
||||||
card.addEventListener('click', () => {
|
if (state.virtualScroller) {
|
||||||
// Check if we're in bulk mode
|
card.classList.add('virtual-scroll-item');
|
||||||
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 autoplayOnHover handlers for video elements if needed
|
// Add video auto-play on hover functionality if needed
|
||||||
const videoElement = card.querySelector('video');
|
const videoElement = card.querySelector('video');
|
||||||
if (videoElement && autoplayOnHover) {
|
if (videoElement && autoplayOnHover) {
|
||||||
const cardPreview = card.querySelector('.card-preview');
|
const cardPreview = card.querySelector('.card-preview');
|
||||||
@@ -287,15 +312,10 @@ export function createLoraCard(lora) {
|
|||||||
videoElement.removeAttribute('autoplay');
|
videoElement.removeAttribute('autoplay');
|
||||||
videoElement.pause();
|
videoElement.pause();
|
||||||
|
|
||||||
// Add mouse events to trigger play/pause
|
// Add mouse events to trigger play/pause using event attributes
|
||||||
cardPreview.addEventListener('mouseenter', () => {
|
// This approach reduces the number of event listeners created
|
||||||
videoElement.play();
|
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
|
||||||
});
|
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
|
||||||
|
|
||||||
cardPreview.addEventListener('mouseleave', () => {
|
|
||||||
videoElement.pause();
|
|
||||||
videoElement.currentTime = 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
@@ -308,7 +328,7 @@ export function updateCardsForBulkMode(isBulkMode) {
|
|||||||
|
|
||||||
document.body.classList.toggle('bulk-mode', 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');
|
const loraCards = document.querySelectorAll('.lora-card');
|
||||||
|
|
||||||
loraCards.forEach(card => {
|
loraCards.forEach(card => {
|
||||||
@@ -330,6 +350,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
|
// Apply selection state to cards if entering bulk mode
|
||||||
if (isBulkMode) {
|
if (isBulkMode) {
|
||||||
bulkManager.applySelectionState();
|
bulkManager.applySelectionState();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class PageControls {
|
|||||||
*/
|
*/
|
||||||
initializeState() {
|
initializeState() {
|
||||||
// Set default values
|
// Set default values
|
||||||
this.pageState.pageSize = 20;
|
this.pageState.pageSize = 100;
|
||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { LorasControls } from './LorasControls.js';
|
import { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
|
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
|
||||||
|
|
||||||
// Export the classes
|
// Export the classes
|
||||||
export { PageControls, LorasControls, CheckpointsControls };
|
export { PageControls, LorasControls, CheckpointsControls };
|
||||||
@@ -20,4 +21,17 @@ export function createPageControls(pageType) {
|
|||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
|||||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||||
|
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
|
||||||
|
|
||||||
// Core application class
|
// Core application class
|
||||||
export class AppCore {
|
export class AppCore {
|
||||||
@@ -63,7 +64,12 @@ export class AppCore {
|
|||||||
// Initialize lazy loading for images on all pages
|
// Initialize lazy loading for images on all pages
|
||||||
lazyLoadImages();
|
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)) {
|
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ class LoraPageManager {
|
|||||||
// Initialize the bulk manager
|
// Initialize the bulk manager
|
||||||
bulkManager.initialize();
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize common page features (lazy loading, infinite scroll)
|
// Initialize common page features (virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export class SettingsManager {
|
|||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
state.global.settings = { ...state.global.settings, ...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() {
|
initialize() {
|
||||||
@@ -76,6 +81,12 @@ export class SettingsManager {
|
|||||||
if (autoplayOnHoverCheckbox) {
|
if (autoplayOnHoverCheckbox) {
|
||||||
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
|
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
|
// Load default lora root
|
||||||
await this.loadLoraRoots();
|
await this.loadLoraRoots();
|
||||||
@@ -149,6 +160,8 @@ export class SettingsManager {
|
|||||||
state.global.settings.autoplayOnHover = value;
|
state.global.settings.autoplayOnHover = value;
|
||||||
} else if (settingKey === 'optimize_example_images') {
|
} else if (settingKey === 'optimize_example_images') {
|
||||||
state.global.settings.optimizeExampleImages = value;
|
state.global.settings.optimizeExampleImages = value;
|
||||||
|
} else if (settingKey === 'compact_mode') {
|
||||||
|
state.global.settings.compactMode = value;
|
||||||
} else {
|
} else {
|
||||||
// For any other settings that might be added in the future
|
// For any other settings that might be added in the future
|
||||||
state.global.settings[settingKey] = value;
|
state.global.settings[settingKey] = value;
|
||||||
@@ -185,6 +198,12 @@ export class SettingsManager {
|
|||||||
this.reloadContent();
|
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) {
|
} catch (error) {
|
||||||
showToast('Failed to save setting: ' + error.message, 'error');
|
showToast('Failed to save setting: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Recipe manager module
|
// Recipe manager module
|
||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
import { RecipeCard } from './components/RecipeCard.js';
|
|
||||||
import { RecipeModal } from './components/RecipeModal.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 { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.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 {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -27,8 +27,8 @@ class RecipeManager {
|
|||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
// Custom filter state
|
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
@@ -49,13 +49,10 @@ class RecipeManager {
|
|||||||
// Check for custom filter parameters in session storage
|
// Check for custom filter parameters in session storage
|
||||||
this._checkCustomFilter();
|
this._checkCustomFilter();
|
||||||
|
|
||||||
// Load initial set of recipes
|
|
||||||
await this.loadRecipes();
|
|
||||||
|
|
||||||
// Expose necessary functions to the page
|
// Expose necessary functions to the page
|
||||||
this._exposeGlobalFunctions();
|
this._exposeGlobalFunctions();
|
||||||
|
|
||||||
// Initialize common page features (lazy loading, infinite scroll)
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +84,7 @@ class RecipeManager {
|
|||||||
|
|
||||||
// Set custom filter if any parameter is present
|
// Set custom filter if any parameter is present
|
||||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: true,
|
active: true,
|
||||||
loraName: filterLoraName,
|
loraName: filterLoraName,
|
||||||
loraHash: filterLoraHash,
|
loraHash: filterLoraHash,
|
||||||
@@ -108,11 +105,11 @@ class RecipeManager {
|
|||||||
// Update text based on filter type
|
// Update text based on filter type
|
||||||
let filterText = '';
|
let filterText = '';
|
||||||
|
|
||||||
if (this.customFilter.recipeId) {
|
if (this.pageState.customFilter.recipeId) {
|
||||||
filterText = 'Viewing specific recipe';
|
filterText = 'Viewing specific recipe';
|
||||||
} else if (this.customFilter.loraName) {
|
} else if (this.pageState.customFilter.loraName) {
|
||||||
// Format with Lora name
|
// Format with Lora name
|
||||||
const loraName = this.customFilter.loraName;
|
const loraName = this.pageState.customFilter.loraName;
|
||||||
const displayName = loraName.length > 25 ?
|
const displayName = loraName.length > 25 ?
|
||||||
loraName.substring(0, 22) + '...' :
|
loraName.substring(0, 22) + '...' :
|
||||||
loraName;
|
loraName;
|
||||||
@@ -125,8 +122,8 @@ class RecipeManager {
|
|||||||
// Update indicator text and show it
|
// Update indicator text and show it
|
||||||
textElement.innerHTML = filterText;
|
textElement.innerHTML = filterText;
|
||||||
// Add title attribute to show the lora name as a tooltip
|
// Add title attribute to show the lora name as a tooltip
|
||||||
if (this.customFilter.loraName) {
|
if (this.pageState.customFilter.loraName) {
|
||||||
textElement.setAttribute('title', this.customFilter.loraName);
|
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||||
}
|
}
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -149,7 +146,7 @@ class RecipeManager {
|
|||||||
|
|
||||||
_clearCustomFilter() {
|
_clearCustomFilter() {
|
||||||
// Reset custom filter
|
// Reset custom filter
|
||||||
this.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: false,
|
active: false,
|
||||||
loraName: null,
|
loraName: null,
|
||||||
loraHash: null,
|
loraHash: null,
|
||||||
@@ -167,8 +164,8 @@ class RecipeManager {
|
|||||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
removeSessionItem('viewRecipeId');
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Reload recipes without custom filter
|
// Reset and refresh the virtual scroller
|
||||||
this.loadRecipes();
|
refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
@@ -177,105 +174,21 @@ class RecipeManager {
|
|||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
this.loadRecipes();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This method is kept for compatibility but now uses virtual scrolling
|
||||||
async loadRecipes(resetPage = true) {
|
async loadRecipes(resetPage = true) {
|
||||||
try {
|
// Skip loading if in duplicates mode
|
||||||
// Skip loading if in duplicates mode
|
const pageState = getCurrentPageState();
|
||||||
const pageState = getCurrentPageState();
|
if (pageState.duplicatesMode) {
|
||||||
if (pageState.duplicatesMode) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
if (resetPage) {
|
||||||
// Show loading indicator
|
refreshVirtualScroll();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,95 +196,7 @@ class RecipeManager {
|
|||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||||
*/
|
*/
|
||||||
async refreshRecipes() {
|
async refreshRecipes() {
|
||||||
try {
|
return refreshRecipes();
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
@@ -396,8 +221,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exitDuplicateMode() {
|
exitDuplicateMode() {
|
||||||
|
// Clear the grid first to prevent showing old content temporarily
|
||||||
|
const recipeGrid = document.getElementById('recipeGrid');
|
||||||
|
if (recipeGrid) {
|
||||||
|
recipeGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
this.duplicatesManager.exitDuplicateMode();
|
this.duplicatesManager.exitDuplicateMode();
|
||||||
initializeInfiniteScroll();
|
|
||||||
|
// Use a small delay before initializing to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeInfiniteScroll('recipes');
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
807
static/js/utils/VirtualScroller.js
Normal file
807
static/js/utils/VirtualScroller.js
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,53 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { loadMoreLoras } from '../api/loraApi.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
import { loadMoreCheckpoints } from '../api/checkpointApi.js';
|
import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js';
|
||||||
import { debounce } from './debounce.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') {
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
// Clean up any existing observer
|
async function getCardCreator(pageType) {
|
||||||
if (state.observer) {
|
if (pageType === 'loras') {
|
||||||
state.observer.disconnect();
|
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
|
// Set the current page type
|
||||||
@@ -17,109 +58,97 @@ export function initializeInfiniteScroll(pageType = 'loras') {
|
|||||||
|
|
||||||
// Skip initializing if in duplicates mode (for recipes page)
|
// Skip initializing if in duplicates mode (for recipes page)
|
||||||
if (pageType === 'recipes' && pageState.duplicatesMode) {
|
if (pageType === 'recipes' && pageState.duplicatesMode) {
|
||||||
|
console.log('Skipping virtual scroll initialization - duplicates mode is active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the load more function and grid ID based on page type
|
// Use virtual scrolling for all page types
|
||||||
let loadMoreFunction;
|
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;
|
let gridId;
|
||||||
|
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'recipes':
|
case 'recipes':
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
window.recipeManager.loadRecipes(false); // false to not reset pagination
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'recipeGrid';
|
gridId = 'recipeGrid';
|
||||||
break;
|
break;
|
||||||
case 'checkpoints':
|
case 'checkpoints':
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
loadMoreCheckpoints(false); // false to not reset
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'checkpointGrid';
|
gridId = 'checkpointGrid';
|
||||||
break;
|
break;
|
||||||
case 'loras':
|
case 'loras':
|
||||||
default:
|
default:
|
||||||
loadMoreFunction = () => {
|
|
||||||
if (!pageState.isLoading && pageState.hasMore) {
|
|
||||||
loadMoreLoras(false); // false to not reset
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gridId = 'loraGrid';
|
gridId = 'loraGrid';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedLoadMore = debounce(loadMoreFunction, 100);
|
|
||||||
|
|
||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
|
|
||||||
if (!grid) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any existing sentinel
|
// Change this line to get the actual scrolling container
|
||||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
const scrollContainer = document.querySelector('.page-content');
|
||||||
if (existingSentinel) {
|
const gridContainer = scrollContainer.querySelector('.container');
|
||||||
existingSentinel.remove();
|
|
||||||
|
if (!gridContainer) {
|
||||||
|
console.warn('Grid container element not found for virtual scroll');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a sentinel element after the grid (not inside it)
|
try {
|
||||||
const sentinel = document.createElement('div');
|
// Get the card creator and data fetcher for this page type
|
||||||
sentinel.id = 'scroll-sentinel';
|
const createCardFn = await getCardCreator(pageType);
|
||||||
sentinel.style.width = '100%';
|
const fetchDataFn = await getDataFetcher(pageType);
|
||||||
sentinel.style.height = '20px';
|
|
||||||
sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout
|
if (!createCardFn || !fetchDataFn) {
|
||||||
|
throw new Error(`Required components not available for ${pageType} page`);
|
||||||
// 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'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize the observer
|
|
||||||
state.observer = new IntersectionObserver((entries) => {
|
|
||||||
const target = entries[0];
|
|
||||||
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
|
|
||||||
debouncedLoadMore();
|
|
||||||
}
|
}
|
||||||
}, observerOptions);
|
|
||||||
|
// Initialize virtual scroller with renamed container elements
|
||||||
// Start observing
|
state.virtualScroller = new VirtualScroller({
|
||||||
state.observer.observe(sentinel);
|
gridElement: grid,
|
||||||
|
containerElement: gridContainer,
|
||||||
// Clean up any existing scroll event listener
|
scrollContainer: scrollContainer,
|
||||||
if (state.scrollHandler) {
|
createItemFn: createCardFn,
|
||||||
window.removeEventListener('scroll', state.scrollHandler);
|
fetchItemsFn: fetchDataFn,
|
||||||
state.scrollHandler = null;
|
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');
|
||||||
|
|
||||||
|
} 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 a simple backup scroll handler
|
|
||||||
const handleScroll = debounce(() => {
|
// Export a method to refresh the virtual scroller when filters change
|
||||||
if (pageState.isLoading || !pageState.hasMore) return;
|
export function refreshVirtualScroll() {
|
||||||
|
if (state.virtualScroller) {
|
||||||
const sentinel = document.getElementById('scroll-sentinel');
|
state.virtualScroller.reset();
|
||||||
if (!sentinel) return;
|
state.virtualScroller.initialize();
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,6 @@ export function showDeleteModal(filePath, modelType = 'lora') {
|
|||||||
export async function confirmDelete() {
|
export async function confirmDelete() {
|
||||||
if (!pendingDeletePath) return;
|
if (!pendingDeletePath) return;
|
||||||
|
|
||||||
const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use appropriate delete function based on model type
|
// Use appropriate delete function based on model type
|
||||||
if (pendingModelType === 'checkpoint') {
|
if (pendingModelType === 'checkpoint') {
|
||||||
@@ -37,10 +35,7 @@ export async function confirmDelete() {
|
|||||||
} else {
|
} else {
|
||||||
await deleteLora(pendingDeletePath);
|
await deleteLora(pendingDeletePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (card) {
|
|
||||||
card.remove();
|
|
||||||
}
|
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting model:', error);
|
console.error('Error deleting model:', error);
|
||||||
|
|||||||
@@ -154,6 +154,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Add Example Images Settings Section -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Example Images</h3>
|
<h3>Example Images</h3>
|
||||||
|
|||||||
@@ -77,43 +77,8 @@
|
|||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div class="card-grid" id="recipeGrid">
|
||||||
{% if recipes and recipes|length > 0 %}
|
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||||
{% for recipe in recipes %}
|
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,61 +1,10 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { dynamicImportByVersion } from "./utils.js";
|
import {
|
||||||
|
getLorasWidgetModule,
|
||||||
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
|
LORA_PATTERN,
|
||||||
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
|
collectActiveLorasFromChain,
|
||||||
|
updateConnectedTriggerWords
|
||||||
// Function to get the appropriate loras widget based on ComfyUI version
|
} from "./utils.js";
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeLoras(lorasText, lorasArr) {
|
function mergeLoras(lorasText, lorasArr) {
|
||||||
const result = [];
|
const result = [];
|
||||||
@@ -107,6 +56,7 @@ app.registerExtension({
|
|||||||
// Restore saved value if exists
|
// Restore saved value if exists
|
||||||
let existingLoras = [];
|
let existingLoras = [];
|
||||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
|
// 0 for input widget, 1 for loras widget
|
||||||
const savedValue = node.widgets_values[1];
|
const savedValue = node.widgets_values[1];
|
||||||
existingLoras = savedValue || [];
|
existingLoras = savedValue || [];
|
||||||
}
|
}
|
||||||
@@ -127,7 +77,7 @@ app.registerExtension({
|
|||||||
// Prevent recursive calls
|
// Prevent recursive calls
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove loras that are not in the value array
|
// Remove loras that are not in the value array
|
||||||
const inputWidget = node.widgets[0];
|
const inputWidget = node.widgets[0];
|
||||||
@@ -143,8 +93,11 @@ app.registerExtension({
|
|||||||
|
|
||||||
inputWidget.value = newText;
|
inputWidget.value = newText;
|
||||||
|
|
||||||
// Add this line to update trigger words when lorasWidget changes cause inputWidget value to change
|
// Collect all active loras from this node and its input chain
|
||||||
updateConnectedTriggerWords(node, newText);
|
const allActiveLoraNames = collectActiveLorasFromChain(node);
|
||||||
|
|
||||||
|
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||||
|
updateConnectedTriggerWords(node, allActiveLoraNames);
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -164,8 +117,11 @@ app.registerExtension({
|
|||||||
|
|
||||||
node.lorasWidget.value = mergedLoras;
|
node.lorasWidget.value = mergedLoras;
|
||||||
|
|
||||||
// Replace the existing trigger word update code with the new function
|
// Collect all active loras from this node and its input chain
|
||||||
updateConnectedTriggerWords(node, value);
|
const allActiveLoraNames = collectActiveLorasFromChain(node);
|
||||||
|
|
||||||
|
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||||
|
updateConnectedTriggerWords(node, allActiveLoraNames);
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,11 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { dynamicImportByVersion } from "./utils.js";
|
import {
|
||||||
|
getLorasWidgetModule,
|
||||||
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
|
LORA_PATTERN,
|
||||||
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
|
getActiveLorasFromNode,
|
||||||
|
collectActiveLorasFromChain,
|
||||||
// Function to get the appropriate loras widget based on ComfyUI version
|
updateConnectedTriggerWords
|
||||||
async function getLorasWidgetModule() {
|
} from "./utils.js";
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeLoras(lorasText, lorasArr) {
|
function mergeLoras(lorasText, lorasArr) {
|
||||||
const result = [];
|
const result = [];
|
||||||
@@ -99,19 +53,9 @@ app.registerExtension({
|
|||||||
// Restore saved value if exists
|
// Restore saved value if exists
|
||||||
let existingLoras = [];
|
let existingLoras = [];
|
||||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
|
// 0 for input widget, 1 for loras widget
|
||||||
const savedValue = node.widgets_values[1];
|
const savedValue = node.widgets_values[1];
|
||||||
// TODO: clean up this code
|
existingLoras = savedValue || [];
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Merge the loras data
|
// Merge the loras data
|
||||||
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
|
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
|
||||||
@@ -145,8 +89,17 @@ app.registerExtension({
|
|||||||
|
|
||||||
inputWidget.value = newText;
|
inputWidget.value = newText;
|
||||||
|
|
||||||
// Update trigger words when lorasWidget changes
|
// Update this stacker's direct trigger toggles with its own active loras
|
||||||
updateConnectedTriggerWords(node, newText);
|
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 {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -166,8 +119,12 @@ app.registerExtension({
|
|||||||
|
|
||||||
node.lorasWidget.value = mergedLoras;
|
node.lorasWidget.value = mergedLoras;
|
||||||
|
|
||||||
// Update trigger words when input changes
|
// Update this stacker's direct trigger toggles with its own active loras
|
||||||
updateConnectedTriggerWords(node, value);
|
const activeLoraNames = getActiveLorasFromNode(node);
|
||||||
|
updateConnectedTriggerWords(node, activeLoraNames);
|
||||||
|
|
||||||
|
// Find all Lora Loader nodes in the chain that might need updates
|
||||||
|
updateDownstreamLoaders(node);
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { api } from "../../scripts/api.js";
|
|
||||||
import { app } from "../../scripts/app.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 } from "./loras_widget_events.js";
|
||||||
|
|
||||||
export function addLorasWidget(node, name, opts, callback) {
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
// Create container for loras
|
// Create container for loras
|
||||||
@@ -27,584 +39,9 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
// Initialize default value
|
// Initialize default value
|
||||||
const defaultValue = opts?.defaultVal || [];
|
const defaultValue = opts?.defaultVal || [];
|
||||||
|
|
||||||
// Fixed sizes for component calculations
|
|
||||||
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
|
||||||
const 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
|
// Create preview tooltip instance
|
||||||
const previewTooltip = new PreviewTooltip();
|
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
|
// Function to render loras from data
|
||||||
const renderLoras = (value, widget) => {
|
const renderLoras = (value, widget) => {
|
||||||
// Clear existing content
|
// Clear existing content
|
||||||
@@ -633,7 +70,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
container.appendChild(emptyMessage);
|
container.appendChild(emptyMessage);
|
||||||
|
|
||||||
// Set fixed height for empty state
|
// Set fixed height for empty state
|
||||||
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
|
updateWidgetHeight(container, EMPTY_CONTAINER_HEIGHT, defaultHeight, node);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,7 +247,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize drag functionality for strength adjustment
|
// 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
|
// Remove the preview tooltip events from loraEl
|
||||||
loraEl.onmouseenter = () => {
|
loraEl.onmouseenter = () => {
|
||||||
@@ -825,7 +262,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
loraEl.addEventListener('contextmenu', (e) => {
|
loraEl.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
createContextMenu(e.clientX, e.clientY, name, widget);
|
createContextMenu(e.clientX, e.clientY, name, widget, previewTooltip, renderLoras);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create strength control
|
// Create strength control
|
||||||
@@ -844,6 +281,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
if (loraIndex >= 0) {
|
if (loraIndex >= 0) {
|
||||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
|
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
|
||||||
|
// Sync clipStrength if collapsed
|
||||||
|
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
widget.value = newValue;
|
||||||
@@ -906,6 +345,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
if (loraIndex >= 0) {
|
if (loraIndex >= 0) {
|
||||||
lorasData[loraIndex].strength = newValue.toFixed(2);
|
lorasData[loraIndex].strength = newValue.toFixed(2);
|
||||||
|
// Sync clipStrength if collapsed
|
||||||
|
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||||
|
|
||||||
// Update value and trigger callback
|
// Update value and trigger callback
|
||||||
const newLorasValue = formatLoraValue(lorasData);
|
const newLorasValue = formatLoraValue(lorasData);
|
||||||
@@ -928,6 +369,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
if (loraIndex >= 0) {
|
if (loraIndex >= 0) {
|
||||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
|
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
|
||||||
|
// Sync clipStrength if collapsed
|
||||||
|
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
widget.value = newValue;
|
||||||
@@ -1125,7 +568,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add drag functionality to clip entry
|
// Add drag functionality to clip entry
|
||||||
initDrag(clipEl, name, widget, true);
|
initDrag(clipEl, name, widget, true, previewTooltip, renderLoras);
|
||||||
|
|
||||||
container.appendChild(clipEl);
|
container.appendChild(clipEl);
|
||||||
}
|
}
|
||||||
@@ -1133,7 +576,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
// Calculate height based on number of loras and fixed sizes
|
// Calculate height based on number of loras and fixed sizes
|
||||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 8) * LORA_ENTRY_HEIGHT);
|
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
|
// Store the value in a variable to avoid recursion
|
||||||
@@ -1219,69 +662,4 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { minWidth: 400, minHeight: defaultHeight, widget };
|
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);
|
|
||||||
}
|
}
|
||||||
303
web/comfyui/loras_widget_components.js
Normal file
303
web/comfyui/loras_widget_components.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
325
web/comfyui/loras_widget_events.js
Normal file
325
web/comfyui/loras_widget_events.js
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
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 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 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);
|
||||||
|
}
|
||||||
166
web/comfyui/loras_widget_utils.js
Normal file
166
web/comfyui/loras_widget_utils.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,4 +63,106 @@ export class DataWrapper {
|
|||||||
setData(data) {
|
setData(data) {
|
||||||
this.data = 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 && loraNames.size > 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user