Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4882721387 | ||
|
|
06a8850c0c | ||
|
|
370aa06c67 | ||
|
|
c9fa0564e7 | ||
|
|
2ba7a0ceba | ||
|
|
276aedfbb9 | ||
|
|
c193c75674 | ||
|
|
a562ba3746 | ||
|
|
2fedd572ff | ||
|
|
db0b49c427 | ||
|
|
03a6f8111c | ||
|
|
925ad7b3e0 | ||
|
|
bf793d5b8b | ||
|
|
64a906ca5e | ||
|
|
99b36442bb | ||
|
|
3c5164d510 |
@@ -9,6 +9,7 @@
|
||||
A comprehensive toolset that streamlines organizing, downloading, and applying LoRA models in ComfyUI. With powerful features like recipe management, checkpoint organization, and one-click workflow integration, working with models becomes faster, smoother, and significantly easier. Access the interface at: `http://localhost:8188/loras`
|
||||
|
||||

|
||||

|
||||
|
||||
## 📺 Tutorial: One-Click LoRA Integration
|
||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
||||
@@ -20,6 +21,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.15
|
||||
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
||||
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
||||
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
|
||||
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
|
||||
|
||||
### v0.8.14
|
||||
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
|
||||
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
|
||||
|
||||
@@ -512,7 +512,7 @@ class ApiRoutes:
|
||||
logger.warning(f"Early access download failed: {error_message}")
|
||||
return web.Response(
|
||||
status=401, # Use 401 status code to match Civitai's response
|
||||
text=f"Early Access Restriction: {error_message}"
|
||||
text=error_message
|
||||
)
|
||||
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import aiohttp
|
||||
from server import PromptServer # type: ignore
|
||||
from aiohttp import web
|
||||
from ..services.settings_manager import settings
|
||||
from ..utils.usage_stats import UsageStats
|
||||
@@ -49,6 +50,9 @@ class MiscRoutes:
|
||||
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images)
|
||||
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images)
|
||||
|
||||
# Lora code update endpoint
|
||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
||||
|
||||
@staticmethod
|
||||
async def update_settings(request):
|
||||
"""Update application settings"""
|
||||
@@ -765,3 +769,62 @@ class MiscRoutes:
|
||||
|
||||
# Set download status to not downloading
|
||||
is_downloading = False
|
||||
|
||||
@staticmethod
|
||||
async def update_lora_code(request):
|
||||
"""
|
||||
Update Lora code in ComfyUI nodes
|
||||
|
||||
Expects a JSON body with:
|
||||
{
|
||||
"node_ids": [123, 456], # List of node IDs to update
|
||||
"lora_code": "<lora:modelname:1.0>", # The Lora code to send
|
||||
"mode": "append" # or "replace" - whether to append or replace existing code
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse the request body
|
||||
data = await request.json()
|
||||
node_ids = data.get('node_ids', [])
|
||||
lora_code = data.get('lora_code', '')
|
||||
mode = data.get('mode', 'append')
|
||||
|
||||
if not node_ids or not lora_code:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing node_ids or lora_code parameter'
|
||||
}, status=400)
|
||||
|
||||
# Send the lora code update to each node
|
||||
results = []
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
# Send the message to the frontend
|
||||
PromptServer.instance.send_sync("lora_code_update", {
|
||||
"id": node_id,
|
||||
"lora_code": lora_code,
|
||||
"mode": mode
|
||||
})
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending lora code to node {node_id}: {e}")
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update lora code: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -6,6 +6,8 @@ import time
|
||||
import shutil
|
||||
from typing import List, Dict, Optional, Type, Set
|
||||
|
||||
from ..utils.model_utils import determine_base_model
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
from ..utils.file_utils import load_metadata, get_file_info, find_preview_file, save_metadata
|
||||
@@ -537,69 +539,113 @@ class ModelScanner:
|
||||
# Common methods shared between scanners
|
||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||
"""Process a single model file and return its metadata"""
|
||||
metadata = await load_metadata(file_path, self.model_class)
|
||||
needs_metadata_update = False
|
||||
original_save_metadata = save_metadata
|
||||
|
||||
if metadata is None:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
|
||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
await save_metadata(file_path, metadata)
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
else:
|
||||
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
|
||||
if metadata.civitai is None or metadata.civitai == {}:
|
||||
# Temporarily override save_metadata to prevent intermediate writes
|
||||
async def no_op_save(*args, **kwargs):
|
||||
nonlocal needs_metadata_update
|
||||
needs_metadata_update = True
|
||||
return None
|
||||
|
||||
# Use a context manager to temporarily replace save_metadata
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def prevent_metadata_writes():
|
||||
nonlocal needs_metadata_update
|
||||
# Replace the function temporarily
|
||||
import sys
|
||||
from .. import utils
|
||||
original = utils.file_utils.save_metadata
|
||||
utils.file_utils.save_metadata = no_op_save
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore the original function
|
||||
utils.file_utils.save_metadata = original
|
||||
|
||||
# Process with write prevention
|
||||
with prevent_metadata_writes():
|
||||
metadata = await load_metadata(file_path, self.model_class)
|
||||
|
||||
if metadata is None:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
||||
metadata.civitai = version_info
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
|
||||
# Ensure tags are also updated if they're missing
|
||||
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
metadata.tags = version_info['model']['tags']
|
||||
|
||||
# Also restore description if missing
|
||||
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
|
||||
if 'description' in version_info['model']:
|
||||
metadata.modelDescription = version_info['model']['description']
|
||||
|
||||
# Save the updated metadata
|
||||
await save_metadata(file_path, metadata)
|
||||
logger.debug(f"Updated metadata with civitai info for {file_path}")
|
||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
needs_metadata_update = True
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
else:
|
||||
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
|
||||
if metadata.civitai is None or metadata.civitai == {}:
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
||||
metadata.civitai = version_info
|
||||
needs_metadata_update = True
|
||||
|
||||
# Ensure tags are also updated if they're missing
|
||||
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
|
||||
if 'tags' in version_info['model']:
|
||||
metadata.tags = version_info['model']['tags']
|
||||
needs_metadata_update = True
|
||||
|
||||
# Also restore description if missing
|
||||
if (not metadata.modelDescription or metadata.modelDescription == "") and 'model' in version_info:
|
||||
if 'description' in version_info['model']:
|
||||
metadata.modelDescription = version_info['model']['description']
|
||||
needs_metadata_update = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||
|
||||
# Check if base_model is consistent with civitai baseModel
|
||||
if metadata.civitai and 'baseModel' in metadata.civitai:
|
||||
civitai_base_model = determine_base_model(metadata.civitai['baseModel'])
|
||||
if metadata.base_model != civitai_base_model:
|
||||
logger.debug(f"Updating base_model from {metadata.base_model} to {civitai_base_model} for {file_path}")
|
||||
metadata.base_model = civitai_base_model
|
||||
needs_metadata_update = True
|
||||
|
||||
if metadata is None:
|
||||
metadata = await self._get_file_info(file_path)
|
||||
if metadata is None:
|
||||
metadata = await self._get_file_info(file_path)
|
||||
needs_metadata_update = True
|
||||
|
||||
model_data = metadata.to_dict()
|
||||
# Continue processing
|
||||
model_data = metadata.to_dict() if metadata else None
|
||||
|
||||
# Skip excluded models
|
||||
if model_data.get('exclude', False):
|
||||
if model_data and model_data.get('exclude', False):
|
||||
self._excluded_models.append(model_data['file_path'])
|
||||
return None
|
||||
|
||||
await self._fetch_missing_metadata(file_path, model_data)
|
||||
# Fetch missing metadata from Civitai if needed (with write prevention)
|
||||
with prevent_metadata_writes():
|
||||
await self._fetch_missing_metadata(file_path, model_data)
|
||||
|
||||
rel_path = os.path.relpath(file_path, root_path)
|
||||
folder = os.path.dirname(rel_path)
|
||||
model_data['folder'] = folder.replace(os.path.sep, '/')
|
||||
|
||||
# Only save metadata if needed
|
||||
if needs_metadata_update and metadata:
|
||||
await original_save_metadata(file_path, metadata)
|
||||
|
||||
return model_data
|
||||
|
||||
async def _fetch_missing_metadata(self, file_path: str, model_data: Dict) -> None:
|
||||
@@ -651,9 +697,9 @@ class ModelScanner:
|
||||
|
||||
model_data['civitai']['creator'] = model_metadata['creator']
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(model_data, f, indent=2, ensure_ascii=False)
|
||||
# Create a metadata object and save it using save_metadata
|
||||
metadata_obj = self.model_class.from_dict(model_data)
|
||||
await save_metadata(file_path, metadata_obj)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
|
||||
@@ -245,8 +245,7 @@ async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = L
|
||||
# needs_update = True
|
||||
|
||||
if needs_update:
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
save_metadata(file_path, model_class.from_dict(data))
|
||||
|
||||
return model_class.from_dict(data)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class ModelRouteUtils:
|
||||
civitai_metadata: Dict, client: CivitaiClient) -> None:
|
||||
"""Update local metadata with CivitAI data"""
|
||||
local_metadata['civitai'] = civitai_metadata
|
||||
local_metadata['from_civitai'] = True
|
||||
|
||||
# Update model name if available
|
||||
if 'model' in civitai_metadata:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.8.14"
|
||||
version = "0.8.15"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -359,6 +359,25 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Prevent text selection on cards and interactive elements */
|
||||
.lora-card,
|
||||
.lora-card *,
|
||||
.card-actions,
|
||||
.card-actions i,
|
||||
.toggle-blur-btn,
|
||||
.show-content-btn,
|
||||
.card-preview img,
|
||||
.card-preview video,
|
||||
.card-footer,
|
||||
.card-header,
|
||||
.model-name,
|
||||
.base-model-label {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Recipe specific elements - migrated from recipe-card.css */
|
||||
.recipe-indicator {
|
||||
position: absolute;
|
||||
|
||||
@@ -2,25 +2,38 @@
|
||||
|
||||
/* Duplicates banner */
|
||||
.duplicates-banner {
|
||||
position: sticky;
|
||||
top: 48px; /* Match header height */
|
||||
left: 0;
|
||||
position: relative; /* Changed from sticky to relative */
|
||||
width: 100%;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 12px 16px;
|
||||
padding: 12px 0; /* Removed horizontal padding */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px; /* Add margin to create space below the banner */
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-content {
|
||||
max-width: 1400px;
|
||||
max-width: 1400px; /* Match the container max-width */
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px; /* Move horizontal padding to the content */
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens - match container in layout.css */
|
||||
@media (min-width: 2000px) {
|
||||
.duplicates-banner .banner-content {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.duplicates-banner .banner-content {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
.duplicates-banner i.fa-exclamation-triangle {
|
||||
|
||||
96
static/css/components/keyboard-nav.css
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Keyboard navigation indicator and help */
|
||||
.keyboard-nav-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: help;
|
||||
transition: all 0.2s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.keyboard-nav-hint:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.keyboard-nav-hint i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Tooltip styling */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 240px;
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 9999; /* 确保在卡片上方显示 */
|
||||
left: 120%; /* 将tooltip显示在图标右侧 */
|
||||
top: 50%; /* 垂直居中 */
|
||||
transform: translateY(-50%); /* 垂直居中 */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--lora-border);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%; /* 箭头垂直居中 */
|
||||
right: 100%; /* 箭头在左侧 */
|
||||
margin-top: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts table */
|
||||
.keyboard-shortcuts {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td {
|
||||
padding: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts td:first-child {
|
||||
font-weight: bold;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: inline-block;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.8em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -35,6 +35,13 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto; /* Push to the right */
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -306,6 +313,26 @@
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Prevent text selection in control and header areas */
|
||||
.tag,
|
||||
.control-group button,
|
||||
.control-group select,
|
||||
.toggle-folders-btn,
|
||||
.bulk-operations-panel,
|
||||
.app-header,
|
||||
.header-branding,
|
||||
.app-title,
|
||||
.main-nav,
|
||||
.nav-item,
|
||||
.header-actions button,
|
||||
.header-controls,
|
||||
.toggle-folders-container button {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
@@ -318,11 +345,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.toggle-folders-container {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.folder-tags-container {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
@import 'components/progress-panel.css';
|
||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
BIN
static/images/one-click-send.jpg
Normal file
|
After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -1,6 +1,6 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||
@@ -35,7 +35,16 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
break;
|
||||
case 'copyname':
|
||||
this.currentCard.querySelector('.fa-copy')?.click();
|
||||
// Generate and copy LoRA syntax
|
||||
this.copyLoraSyntax();
|
||||
break;
|
||||
case 'sendappend':
|
||||
// Send LoRA to workflow (append mode)
|
||||
this.sendLoraToWorkflow(false);
|
||||
break;
|
||||
case 'sendreplace':
|
||||
// Send LoRA to workflow (replace mode)
|
||||
this.sendLoraToWorkflow(true);
|
||||
break;
|
||||
case 'preview':
|
||||
this.currentCard.querySelector('.fa-image')?.click();
|
||||
@@ -58,6 +67,26 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// New method to handle copy syntax functionality
|
||||
copyLoraSyntax() {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
||||
}
|
||||
|
||||
// New method to handle send to workflow functionality
|
||||
sendLoraToWorkflow(replaceMode) {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
}
|
||||
|
||||
// NSFW Selector methods from the original context menu
|
||||
initNSFWSelector() {
|
||||
// Close button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
@@ -39,8 +39,16 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
this.currentCard.click();
|
||||
break;
|
||||
case 'copy':
|
||||
// Copy recipe to clipboard
|
||||
this.currentCard.querySelector('.fa-copy')?.click();
|
||||
// Copy recipe syntax to clipboard
|
||||
this.copyRecipeSyntax();
|
||||
break;
|
||||
case 'sendappend':
|
||||
// Send recipe to workflow (append mode)
|
||||
this.sendRecipeToWorkflow(false);
|
||||
break;
|
||||
case 'sendreplace':
|
||||
// Send recipe to workflow (replace mode)
|
||||
this.sendRecipeToWorkflow(true);
|
||||
break;
|
||||
case 'share':
|
||||
// Share recipe
|
||||
@@ -61,6 +69,52 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// New method to copy recipe syntax to clipboard
|
||||
copyRecipeSyntax() {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy recipe syntax: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// New method to send recipe to workflow
|
||||
sendRecipeToWorkflow(replaceMode) {
|
||||
const recipeId = this.currentCard.dataset.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to send recipe to workflow: ', err);
|
||||
showToast('Failed to send recipe to workflow', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// View all LoRAs in the recipe
|
||||
viewRecipeLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { showToast, openCivitai, copyToClipboard } from '../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showLoraModal } from './loraModal/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
@@ -51,9 +51,9 @@ function handleLoraCardEvent(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-copy')) {
|
||||
if (event.target.closest('.fa-paper-plane')) {
|
||||
event.stopPropagation();
|
||||
copyLoraCode(card);
|
||||
sendLoraToComfyUI(card, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,12 +173,13 @@ async function toggleFavorite(card) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLoraCode(card) {
|
||||
// Function to send LoRA to ComfyUI workflow
|
||||
async function sendLoraToComfyUI(card, replaceMode) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
}
|
||||
|
||||
export function createLoraCard(lora) {
|
||||
@@ -269,8 +270,8 @@ export function createLoraCard(lora) {
|
||||
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-copy"
|
||||
title="Copy LoRA Syntax">
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||
</i>
|
||||
<i class="fas fa-trash"
|
||||
title="Delete Model">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Card Component
|
||||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
|
||||
@@ -52,7 +52,7 @@ class RecipeCard {
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +94,10 @@ class RecipeCard {
|
||||
this.shareRecipe();
|
||||
});
|
||||
|
||||
// Copy button click event - prevent propagation to card
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
||||
// Send button click event - prevent propagation to card
|
||||
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.copyRecipeSyntax();
|
||||
this.sendRecipeToWorkflow(e.shiftKey);
|
||||
});
|
||||
|
||||
// Delete button click event - prevent propagation to card
|
||||
@@ -108,33 +108,32 @@ class RecipeCard {
|
||||
}
|
||||
}
|
||||
|
||||
copyRecipeSyntax() {
|
||||
// Replace copyRecipeSyntax with sendRecipeToWorkflow
|
||||
sendRecipeToWorkflow(replaceMode = false) {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fallback if button not found
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
console.error('Failed to send recipe to workflow: ', err);
|
||||
showToast('Failed to send recipe to workflow', 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error copying recipe syntax:', error);
|
||||
showToast('Error copying recipe syntax', 'error');
|
||||
console.error('Error sending recipe to workflow:', error);
|
||||
showToast('Error sending recipe to workflow', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -804,4 +804,131 @@ export class VirtualScroller {
|
||||
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add keyboard navigation methods
|
||||
handlePageUpDown(direction) {
|
||||
// Prevent duplicate animations by checking last trigger time
|
||||
const now = Date.now();
|
||||
if (this.lastPageNavTime && now - this.lastPageNavTime < 300) {
|
||||
return; // Ignore rapid repeated triggers
|
||||
}
|
||||
this.lastPageNavTime = now;
|
||||
|
||||
const scrollContainer = this.scrollContainer;
|
||||
const viewportHeight = scrollContainer.clientHeight;
|
||||
|
||||
// Calculate scroll distance (one viewport minus 10% overlap for context)
|
||||
const scrollDistance = viewportHeight * 0.9;
|
||||
|
||||
// Determine the new scroll position
|
||||
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
|
||||
|
||||
// Remove any existing transition indicators
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
// Scroll to the new position with smooth animation
|
||||
scrollContainer.scrollTo({
|
||||
top: newScrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
// Force render after scrolling
|
||||
setTimeout(() => this.renderItems(), 100);
|
||||
setTimeout(() => this.renderItems(), 300);
|
||||
}
|
||||
|
||||
// Helper to remove existing indicators
|
||||
removeExistingTransitionIndicator() {
|
||||
const existingIndicator = document.querySelector('.page-transition-indicator');
|
||||
if (existingIndicator) {
|
||||
existingIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a more contained transition indicator - commented out as it's no longer needed
|
||||
/*
|
||||
showTransitionIndicator() {
|
||||
const container = this.containerElement;
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'page-transition-indicator';
|
||||
|
||||
// Get container position to properly position the indicator
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Style the indicator to match just the container area
|
||||
indicator.style.position = 'fixed';
|
||||
indicator.style.top = `${containerRect.top}px`;
|
||||
indicator.style.left = `${containerRect.left}px`;
|
||||
indicator.style.width = `${containerRect.width}px`;
|
||||
indicator.style.height = `${containerRect.height}px`;
|
||||
|
||||
document.body.appendChild(indicator);
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
if (indicator.parentNode) {
|
||||
indicator.remove();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
*/
|
||||
|
||||
scrollToTop() {
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
this.scrollContainer.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Force render after scrolling
|
||||
setTimeout(() => this.renderItems(), 100);
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
// Start loading all remaining pages to ensure content is available
|
||||
this.loadRemainingPages().then(() => {
|
||||
// After loading all content, scroll to the very bottom
|
||||
const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
|
||||
this.scrollContainer.scrollTo({
|
||||
top: maxScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// New method to load all remaining pages
|
||||
async loadRemainingPages() {
|
||||
// If we're already at the end or loading, don't proceed
|
||||
if (!this.hasMore || this.isLoading) return;
|
||||
|
||||
console.log('Loading all remaining pages for End key navigation...');
|
||||
|
||||
// Keep loading pages until we reach the end
|
||||
while (this.hasMore && !this.isLoading) {
|
||||
await this.loadMoreItems();
|
||||
|
||||
// Force render after each page load
|
||||
this.renderItems();
|
||||
|
||||
// Small delay to prevent overwhelming the browser
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
console.log('Finished loading all pages');
|
||||
|
||||
// Final render to ensure all content is displayed
|
||||
this.renderItems();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,9 @@ async function initializeVirtualScroll(pageType) {
|
||||
// Add grid class for CSS styling
|
||||
grid.classList.add('virtual-scroll');
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNavigation();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
|
||||
@@ -145,6 +148,61 @@ async function initializeVirtualScroll(pageType) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add keyboard navigation setup function
|
||||
function setupKeyboardNavigation() {
|
||||
// Keep track of the last keypress time to prevent multiple rapid triggers
|
||||
let lastKeyTime = 0;
|
||||
const keyDelay = 300; // ms between allowed keypresses
|
||||
|
||||
// Store the event listener reference so we can remove it later if needed
|
||||
const keyboardNavHandler = (event) => {
|
||||
// Only handle keyboard events when not in form elements
|
||||
if (event.target.matches('input, textarea, select')) return;
|
||||
|
||||
// Prevent rapid keypresses
|
||||
const now = Date.now();
|
||||
if (now - lastKeyTime < keyDelay) return;
|
||||
lastKeyTime = now;
|
||||
|
||||
// Handle navigation keys
|
||||
if (event.key === 'PageUp') {
|
||||
event.preventDefault();
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.handlePageUpDown('up');
|
||||
}
|
||||
} else if (event.key === 'PageDown') {
|
||||
event.preventDefault();
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.handlePageUpDown('down');
|
||||
}
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.scrollToTop();
|
||||
}
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.scrollToBottom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
document.addEventListener('keydown', keyboardNavHandler);
|
||||
|
||||
// Store the handler in state for potential cleanup
|
||||
state.keyboardNavHandler = keyboardNavHandler;
|
||||
}
|
||||
|
||||
// Add cleanup function to remove keyboard navigation when needed
|
||||
export function cleanupKeyboardNavigation() {
|
||||
if (state.keyboardNavHandler) {
|
||||
document.removeEventListener('keydown', state.keyboardNavHandler);
|
||||
state.keyboardNavHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a method to refresh the virtual scroller when filters change
|
||||
export function refreshVirtualScroll() {
|
||||
if (state.virtualScroller) {
|
||||
|
||||
@@ -351,4 +351,72 @@ export function getNSFWLevelName(level) {
|
||||
if (level >= 2) return 'PG13';
|
||||
if (level >= 1) return 'PG';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends LoRA syntax to the active ComfyUI workflow
|
||||
* @param {string} loraSyntax - The LoRA syntax to send
|
||||
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
|
||||
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
||||
* @returns {Promise<boolean>} - Whether the operation was successful
|
||||
*/
|
||||
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
|
||||
try {
|
||||
// Get the current workflow from localStorage
|
||||
const workflowData = localStorage.getItem('workflow');
|
||||
if (!workflowData) {
|
||||
showToast('No active workflow found', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the workflow JSON
|
||||
const workflow = JSON.parse(workflowData);
|
||||
|
||||
// Find all Lora Loader (LoraManager) nodes
|
||||
const loraNodes = [];
|
||||
if (workflow.nodes && Array.isArray(workflow.nodes)) {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.type === "Lora Loader (LoraManager)") {
|
||||
loraNodes.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loraNodes.length === 0) {
|
||||
showToast('No Lora Loader nodes found in the workflow', 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call the backend API to update the lora code
|
||||
const response = await fetch('/api/update-lora-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_ids: loraNodes,
|
||||
lora_code: loraSyntax,
|
||||
mode: replaceMode ? 'replace' : 'append'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Use different toast messages based on syntax type
|
||||
if (syntaxType === 'recipe') {
|
||||
showToast(`Recipe ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
|
||||
} else {
|
||||
showToast(`LoRA ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
showToast(result.error || `Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send to workflow:', error);
|
||||
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@
|
||||
<div class="context-menu-item" data-action="copyname">
|
||||
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="sendappend">
|
||||
<i class="fas fa-paper-plane"></i> Send to Workflow (Append)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="sendreplace">
|
||||
<i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="preview">
|
||||
<i class="fas fa-image"></i> Replace Preview
|
||||
</div>
|
||||
|
||||
@@ -47,10 +47,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-folders-container">
|
||||
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
|
||||
<i class="fas fa-tags"></i>
|
||||
</button>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="toggle-folders-container">
|
||||
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
|
||||
<i class="fas fa-tags"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-nav-hint tooltip">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
<span class="tooltiptext">
|
||||
Keyboard Navigation:
|
||||
<table class="keyboard-shortcuts">
|
||||
<tr>
|
||||
<td><span class="key">Page Up</span></td>
|
||||
<td>Scroll up one page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Page Down</span></td>
|
||||
<td>Scroll down one page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">Home</span></td>
|
||||
<td>Jump to top</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">End</span></td>
|
||||
<td>Jump to bottom</td>
|
||||
</tr>
|
||||
</table>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div>
|
||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe Syntax</div>
|
||||
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> Send to Workflow (Append)</div>
|
||||
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)</div>
|
||||
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>
|
||||
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
collectActiveLorasFromChain,
|
||||
updateConnectedTriggerWords
|
||||
} from "./utils.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
@@ -38,6 +39,45 @@ function mergeLoras(lorasText, lorasArr) {
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraLoader",
|
||||
|
||||
setup() {
|
||||
// Add message handler to listen for messages from Python
|
||||
api.addEventListener("lora_code_update", (event) => {
|
||||
const { id, lora_code, mode } = event.detail;
|
||||
this.handleLoraCodeUpdate(id, lora_code, mode);
|
||||
});
|
||||
},
|
||||
|
||||
// Handle lora code updates from Python
|
||||
handleLoraCodeUpdate(id, loraCode, mode) {
|
||||
const node = app.graph.getNodeById(+id);
|
||||
if (!node || node.comfyClass !== "Lora Loader (LoraManager)") {
|
||||
console.warn("Node not found or not a LoraLoader:", id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the input widget with new lora code
|
||||
const inputWidget = node.widgets[0];
|
||||
if (!inputWidget) return;
|
||||
|
||||
// Get the current lora code
|
||||
const currentValue = inputWidget.value || '';
|
||||
|
||||
// Update based on mode (replace or append)
|
||||
if (mode === 'replace') {
|
||||
inputWidget.value = loraCode;
|
||||
} else {
|
||||
// Append mode - add a space if the current value isn't empty
|
||||
inputWidget.value = currentValue.trim()
|
||||
? `${currentValue.trim()} ${loraCode}`
|
||||
: loraCode;
|
||||
}
|
||||
|
||||
// Trigger the callback to update the loras widget
|
||||
if (typeof inputWidget.callback === 'function') {
|
||||
inputWidget.callback(inputWidget.value);
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "Lora Loader (LoraManager)") {
|
||||
// Enable widget serialization
|
||||
@@ -74,6 +114,12 @@ app.registerExtension({
|
||||
const result = addLorasWidget(node, "loras", {
|
||||
defaultVal: mergedLoras // Pass object directly
|
||||
}, (value) => {
|
||||
// Collect all active loras from this node and its input chain
|
||||
const allActiveLoraNames = collectActiveLorasFromChain(node);
|
||||
|
||||
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||
updateConnectedTriggerWords(node, allActiveLoraNames);
|
||||
|
||||
// Prevent recursive calls
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
@@ -92,12 +138,6 @@ app.registerExtension({
|
||||
newText = newText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
inputWidget.value = newText;
|
||||
|
||||
// Collect all active loras from this node and its input chain
|
||||
const allActiveLoraNames = collectActiveLorasFromChain(node);
|
||||
|
||||
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||
updateConnectedTriggerWords(node, allActiveLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
@@ -116,12 +156,6 @@ app.registerExtension({
|
||||
const mergedLoras = mergeLoras(value, currentLoras);
|
||||
|
||||
node.lorasWidget.value = mergedLoras;
|
||||
|
||||
// Collect all active loras from this node and its input chain
|
||||
const allActiveLoraNames = collectActiveLorasFromChain(node);
|
||||
|
||||
// Update trigger words for connected toggle nodes with the aggregated lora names
|
||||
updateConnectedTriggerWords(node, allActiveLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
CONTAINER_PADDING,
|
||||
EMPTY_CONTAINER_HEIGHT
|
||||
} from "./loras_widget_utils.js";
|
||||
import { initDrag, createContextMenu } from "./loras_widget_events.js";
|
||||
import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js";
|
||||
|
||||
export function addLorasWidget(node, name, opts, callback) {
|
||||
// Create container for loras
|
||||
@@ -83,7 +83,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||
marginBottom: "5px"
|
||||
marginBottom: "5px",
|
||||
position: "relative" // Added for positioning the drag hint
|
||||
});
|
||||
|
||||
// Add toggle all control
|
||||
@@ -118,7 +119,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
toggleContainer.appendChild(toggleAll);
|
||||
toggleContainer.appendChild(toggleLabel);
|
||||
|
||||
// Strength label
|
||||
// Strength label with drag hint
|
||||
const strengthLabel = document.createElement("div");
|
||||
strengthLabel.textContent = "Strength";
|
||||
Object.assign(strengthLabel.style, {
|
||||
@@ -129,11 +130,36 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
});
|
||||
|
||||
// Add drag hint icon next to strength label
|
||||
const dragHint = document.createElement("span");
|
||||
dragHint.innerHTML = "↔"; // Simple left-right arrow as drag indicator
|
||||
Object.assign(dragHint.style, {
|
||||
marginLeft: "5px",
|
||||
fontSize: "11px",
|
||||
opacity: "0.6",
|
||||
transition: "opacity 0.2s ease"
|
||||
});
|
||||
strengthLabel.appendChild(dragHint);
|
||||
|
||||
// Add hover effect to improve discoverability
|
||||
header.addEventListener("mouseenter", () => {
|
||||
dragHint.style.opacity = "1";
|
||||
});
|
||||
|
||||
header.addEventListener("mouseleave", () => {
|
||||
dragHint.style.opacity = "0.6";
|
||||
});
|
||||
|
||||
header.appendChild(toggleContainer);
|
||||
header.appendChild(strengthLabel);
|
||||
container.appendChild(header);
|
||||
|
||||
// Initialize the header drag functionality
|
||||
initHeaderDrag(header, widget, renderLoras);
|
||||
|
||||
// Track the total visible entries for height calculation
|
||||
let totalVisibleEntries = lorasData.length;
|
||||
|
||||
@@ -46,6 +46,55 @@ export function handleStrengthDrag(name, initialStrength, initialX, event, widge
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle proportional strength adjustment for all LoRAs via header dragging
|
||||
export function handleAllStrengthsDrag(initialStrengths, initialX, event, widget) {
|
||||
// Define sensitivity (less sensitive than individual adjustment)
|
||||
const sensitivity = 0.0005;
|
||||
|
||||
// Get current mouse position
|
||||
const currentX = event.clientX;
|
||||
|
||||
// Calculate the distance moved
|
||||
const deltaX = currentX - initialX;
|
||||
|
||||
// Calculate adjustment factor (1.0 means no change, >1.0 means increase, <1.0 means decrease)
|
||||
// For positive deltaX, we want to increase strengths, for negative we want to decrease
|
||||
const adjustmentFactor = 1.0 + (deltaX * sensitivity);
|
||||
|
||||
// Ensure adjustment factor is reasonable (prevent extreme changes)
|
||||
const limitedFactor = Math.max(0.01, Math.min(3.0, adjustmentFactor));
|
||||
|
||||
// Get current loras data
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
|
||||
// Apply the adjustment factor to each LoRA's strengths
|
||||
lorasData.forEach((loraData, index) => {
|
||||
// Get initial strengths for this LoRA
|
||||
const initialModelStrength = initialStrengths[index].modelStrength;
|
||||
const initialClipStrength = initialStrengths[index].clipStrength;
|
||||
|
||||
// Apply the adjustment factor to both strengths
|
||||
let newModelStrength = (initialModelStrength * limitedFactor).toFixed(2);
|
||||
let newClipStrength = (initialClipStrength * limitedFactor).toFixed(2);
|
||||
|
||||
// Limit the values to reasonable bounds (-10 to 10)
|
||||
newModelStrength = Math.max(-10, Math.min(10, newModelStrength));
|
||||
newClipStrength = Math.max(-10, Math.min(10, newClipStrength));
|
||||
|
||||
// Update strengths
|
||||
lorasData[index].strength = Number(newModelStrength);
|
||||
lorasData[index].clipStrength = Number(newClipStrength);
|
||||
});
|
||||
|
||||
// Update widget value
|
||||
widget.value = formatLoraValue(lorasData);
|
||||
|
||||
// Force re-render via callback
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to initialize drag operation
|
||||
export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) {
|
||||
let isDragging = false;
|
||||
@@ -119,6 +168,65 @@ export function initDrag(dragEl, name, widget, isClipStrength = false, previewTo
|
||||
});
|
||||
}
|
||||
|
||||
// Function to initialize header drag for proportional strength adjustment
|
||||
export function initHeaderDrag(headerEl, widget, renderFunction) {
|
||||
let isDragging = false;
|
||||
let initialX = 0;
|
||||
let initialStrengths = [];
|
||||
|
||||
// Add cursor style to indicate draggable
|
||||
headerEl.style.cursor = 'ew-resize';
|
||||
|
||||
// Create a drag handler
|
||||
headerEl.addEventListener('mousedown', (e) => {
|
||||
// Skip if clicking on toggle or other interactive elements
|
||||
if (e.target.closest('.comfy-lora-toggle') ||
|
||||
e.target.closest('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store initial X position
|
||||
initialX = e.clientX;
|
||||
|
||||
// Store initial strengths of all LoRAs
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
initialStrengths = lorasData.map(lora => ({
|
||||
modelStrength: Number(lora.strength),
|
||||
clipStrength: Number(lora.clipStrength)
|
||||
}));
|
||||
|
||||
isDragging = true;
|
||||
|
||||
// Add class to body to enforce cursor style globally
|
||||
document.body.classList.add('comfy-lora-dragging');
|
||||
|
||||
// Prevent text selection during drag
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Handle mouse move for dragging
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
// Call the strength adjustment function
|
||||
handleAllStrengthsDrag(initialStrengths, initialX, e, widget);
|
||||
|
||||
// Force re-render to show updated strength values
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mouse up to end dragging
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Remove the class to restore normal cursor behavior
|
||||
document.body.classList.remove('comfy-lora-dragging');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to create context menu
|
||||
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
||||
// Hide preview tooltip first
|
||||
|
||||
@@ -155,7 +155,7 @@ export function collectActiveLorasFromChain(node, visited = new Set()) {
|
||||
// Update trigger words for connected toggle nodes
|
||||
export function updateConnectedTriggerWords(node, loraNames) {
|
||||
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
|
||||
if (connectedNodeIds.length > 0 && loraNames.size > 0) {
|
||||
if (connectedNodeIds.length > 0) {
|
||||
fetch("/loramanager/get_trigger_words", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
BIN
wiki-images/import-recipe-url.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
wiki-images/lora-recipes-tab.png
Normal file
|
After Width: | Height: | Size: 872 KiB |
BIN
wiki-images/recipe-detail.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
wiki-images/recipe-download-missing.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
wiki-images/recipe-find-duplicates.png
Normal file
|
After Width: | Height: | Size: 400 KiB |
BIN
wiki-images/recipe-reconnect-deleted.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
wiki-images/recipe-save.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
wiki-images/recipe-send-to-workflow.png
Normal file
|
After Width: | Height: | Size: 639 KiB |
BIN
wiki-images/recipe-source-path.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
wiki-images/recipe-view-loras.png
Normal file
|
After Width: | Height: | Size: 529 KiB |