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`
|
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
|
## 📺 Tutorial: One-Click LoRA Integration
|
||||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
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
|
## 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
|
### 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
|
* **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)
|
* **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}")
|
logger.warning(f"Early access download failed: {error_message}")
|
||||||
return web.Response(
|
return web.Response(
|
||||||
status=401, # Use 401 status code to match Civitai's 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)
|
return web.Response(status=500, text=error_message)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from server import PromptServer # type: ignore
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
from ..utils.usage_stats import UsageStats
|
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/pause-example-images', MiscRoutes.pause_example_images)
|
||||||
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_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
|
@staticmethod
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update application settings"""
|
"""Update application settings"""
|
||||||
@@ -765,3 +769,62 @@ class MiscRoutes:
|
|||||||
|
|
||||||
# Set download status to not downloading
|
# Set download status to not downloading
|
||||||
is_downloading = False
|
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
|
import shutil
|
||||||
from typing import List, Dict, Optional, Type, Set
|
from typing import List, Dict, Optional, Type, Set
|
||||||
|
|
||||||
|
from ..utils.model_utils import determine_base_model
|
||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import load_metadata, get_file_info, find_preview_file, save_metadata
|
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
|
# Common methods shared between scanners
|
||||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||||
"""Process a single model file and return its metadata"""
|
"""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:
|
# Temporarily override save_metadata to prevent intermediate writes
|
||||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
async def no_op_save(*args, **kwargs):
|
||||||
if os.path.exists(civitai_info_path):
|
nonlocal needs_metadata_update
|
||||||
try:
|
needs_metadata_update = True
|
||||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
return None
|
||||||
version_info = json.load(f)
|
|
||||||
|
|
||||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
# Use a context manager to temporarily replace save_metadata
|
||||||
if file_info:
|
from contextlib import contextmanager
|
||||||
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)
|
@contextmanager
|
||||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
def prevent_metadata_writes():
|
||||||
await save_metadata(file_path, metadata)
|
nonlocal needs_metadata_update
|
||||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
# Replace the function temporarily
|
||||||
except Exception as e:
|
import sys
|
||||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
from .. import utils
|
||||||
else:
|
original = utils.file_utils.save_metadata
|
||||||
# Check if metadata exists but civitai field is empty - try to restore from civitai.info
|
utils.file_utils.save_metadata = no_op_save
|
||||||
if metadata.civitai is None or metadata.civitai == {}:
|
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"
|
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||||
if os.path.exists(civitai_info_path):
|
if os.path.exists(civitai_info_path):
|
||||||
try:
|
try:
|
||||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||||
version_info = json.load(f)
|
version_info = json.load(f)
|
||||||
|
|
||||||
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||||
metadata.civitai = version_info
|
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
|
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
||||||
if (not metadata.tags or len(metadata.tags) == 0) and 'model' in version_info:
|
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||||
if 'tags' in version_info['model']:
|
needs_metadata_update = True
|
||||||
metadata.tags = version_info['model']['tags']
|
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||||
|
|
||||||
# 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}")
|
|
||||||
except Exception as e:
|
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)
|
||||||
|
|
||||||
if metadata is None:
|
logger.debug(f"Restoring missing civitai data from .civitai.info for {file_path}")
|
||||||
metadata = await self._get_file_info(file_path)
|
metadata.civitai = version_info
|
||||||
|
needs_metadata_update = True
|
||||||
|
|
||||||
model_data = metadata.to_dict()
|
# 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)
|
||||||
|
needs_metadata_update = True
|
||||||
|
|
||||||
|
# Continue processing
|
||||||
|
model_data = metadata.to_dict() if metadata else None
|
||||||
|
|
||||||
# Skip excluded models
|
# 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'])
|
self._excluded_models.append(model_data['file_path'])
|
||||||
return None
|
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)
|
rel_path = os.path.relpath(file_path, root_path)
|
||||||
folder = os.path.dirname(rel_path)
|
folder = os.path.dirname(rel_path)
|
||||||
model_data['folder'] = folder.replace(os.path.sep, '/')
|
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
|
return model_data
|
||||||
|
|
||||||
async def _fetch_missing_metadata(self, file_path: str, model_data: Dict) -> None:
|
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']
|
model_data['civitai']['creator'] = model_metadata['creator']
|
||||||
|
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
# Create a metadata object and save it using save_metadata
|
||||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
metadata_obj = self.model_class.from_dict(model_data)
|
||||||
json.dump(model_data, f, indent=2, ensure_ascii=False)
|
await save_metadata(file_path, metadata_obj)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {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
|
# needs_update = True
|
||||||
|
|
||||||
if needs_update:
|
if needs_update:
|
||||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
save_metadata(file_path, model_class.from_dict(data))
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
return model_class.from_dict(data)
|
return model_class.from_dict(data)
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class ModelRouteUtils:
|
|||||||
civitai_metadata: Dict, client: CivitaiClient) -> None:
|
civitai_metadata: Dict, client: CivitaiClient) -> None:
|
||||||
"""Update local metadata with CivitAI data"""
|
"""Update local metadata with CivitAI data"""
|
||||||
local_metadata['civitai'] = civitai_metadata
|
local_metadata['civitai'] = civitai_metadata
|
||||||
|
local_metadata['from_civitai'] = True
|
||||||
|
|
||||||
# Update model name if available
|
# Update model name if available
|
||||||
if 'model' in civitai_metadata:
|
if 'model' in civitai_metadata:
|
||||||
|
|||||||
@@ -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.14"
|
version = "0.8.15"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -359,6 +359,25 @@
|
|||||||
font-size: 0.85em;
|
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 specific elements - migrated from recipe-card.css */
|
||||||
.recipe-indicator {
|
.recipe-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -2,25 +2,38 @@
|
|||||||
|
|
||||||
/* Duplicates banner */
|
/* Duplicates banner */
|
||||||
.duplicates-banner {
|
.duplicates-banner {
|
||||||
position: sticky;
|
position: relative; /* Changed from sticky to relative */
|
||||||
top: 48px; /* Match header height */
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
z-index: var(--z-overlay);
|
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);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 20px; /* Add margin to create space below the banner */
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner .banner-content {
|
.duplicates-banner .banner-content {
|
||||||
max-width: 1400px;
|
max-width: 1400px; /* Match the container max-width */
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
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 {
|
.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);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto; /* Push to the right */
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -306,6 +313,26 @@
|
|||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -318,11 +345,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-folders-container {
|
.toggle-folders-container {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-tags-container {
|
.folder-tags-container {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
@import 'components/progress-panel.css';
|
@import 'components/progress-panel.css';
|
||||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||||
|
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
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 { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.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 { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
@@ -35,7 +35,16 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'copyname':
|
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;
|
break;
|
||||||
case 'preview':
|
case 'preview':
|
||||||
this.currentCard.querySelector('.fa-image')?.click();
|
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
|
// NSFW Selector methods from the original context menu
|
||||||
initNSFWSelector() {
|
initNSFWSelector() {
|
||||||
// Close button
|
// Close button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
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 { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
@@ -39,8 +39,16 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
this.currentCard.click();
|
this.currentCard.click();
|
||||||
break;
|
break;
|
||||||
case 'copy':
|
case 'copy':
|
||||||
// Copy recipe to clipboard
|
// Copy recipe syntax to clipboard
|
||||||
this.currentCard.querySelector('.fa-copy')?.click();
|
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;
|
break;
|
||||||
case 'share':
|
case 'share':
|
||||||
// Share recipe
|
// 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
|
// View all LoRAs in the recipe
|
||||||
viewRecipeLoRAs(recipeId) {
|
viewRecipeLoRAs(recipeId) {
|
||||||
if (!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 { state } from '../state/index.js';
|
||||||
import { showLoraModal } from './loraModal/index.js';
|
import { showLoraModal } from './loraModal/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
@@ -51,9 +51,9 @@ function handleLoraCardEvent(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-copy')) {
|
if (event.target.closest('.fa-paper-plane')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
copyLoraCode(card);
|
sendLoraToComfyUI(card, event.shiftKey);
|
||||||
return;
|
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 usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||||
|
|
||||||
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
|
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLoraCard(lora) {
|
export function createLoraCard(lora) {
|
||||||
@@ -269,8 +270,8 @@ export function createLoraCard(lora) {
|
|||||||
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||||
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-copy"
|
<i class="fas fa-paper-plane"
|
||||||
title="Copy LoRA Syntax">
|
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-trash"
|
<i class="fas fa-trash"
|
||||||
title="Delete Model">
|
title="Delete Model">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Card Component
|
// 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 { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class RecipeCard {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
<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>
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,10 +94,10 @@ class RecipeCard {
|
|||||||
this.shareRecipe();
|
this.shareRecipe();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy button click event - prevent propagation to card
|
// Send button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.copyRecipeSyntax();
|
this.sendRecipeToWorkflow(e.shiftKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete button click event - prevent propagation to card
|
// Delete button click event - prevent propagation to card
|
||||||
@@ -108,33 +108,32 @@ class RecipeCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyRecipeSyntax() {
|
// Replace copyRecipeSyntax with sendRecipeToWorkflow
|
||||||
|
sendRecipeToWorkflow(replaceMode = false) {
|
||||||
try {
|
try {
|
||||||
// Get recipe ID
|
// Get recipe ID
|
||||||
const recipeId = this.recipe.id;
|
const recipeId = this.recipe.id;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Fallback if button not found
|
|
||||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.syntax) {
|
if (data.success && data.syntax) {
|
||||||
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'No syntax returned');
|
throw new Error(data.error || 'No syntax returned');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to copy: ', err);
|
console.error('Failed to send recipe to workflow: ', err);
|
||||||
showToast('Failed to copy recipe syntax', 'error');
|
showToast('Failed to send recipe to workflow', 'error');
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying recipe syntax:', error);
|
console.error('Error sending recipe to workflow:', error);
|
||||||
showToast('Error copying recipe syntax', '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`);
|
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
||||||
return true;
|
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
|
// Add grid class for CSS styling
|
||||||
grid.classList.add('virtual-scroll');
|
grid.classList.add('virtual-scroll');
|
||||||
|
|
||||||
|
// Setup keyboard navigation
|
||||||
|
setupKeyboardNavigation();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||||
showToast(`Failed to initialize ${pageType} page. Please reload.`, '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 a method to refresh the virtual scroller when filters change
|
||||||
export function refreshVirtualScroll() {
|
export function refreshVirtualScroll() {
|
||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
|
|||||||
@@ -352,3 +352,71 @@ export function getNSFWLevelName(level) {
|
|||||||
if (level >= 1) return 'PG';
|
if (level >= 1) return 'PG';
|
||||||
return 'Unknown';
|
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">
|
<div class="context-menu-item" data-action="copyname">
|
||||||
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
||||||
</div>
|
</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">
|
<div class="context-menu-item" data-action="preview">
|
||||||
<i class="fas fa-image"></i> Replace Preview
|
<i class="fas fa-image"></i> Replace Preview
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,10 +47,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toggle-folders-container">
|
|
||||||
<button class="toggle-folders-btn icon-only" title="Toggle folder tags">
|
<div class="controls-right">
|
||||||
<i class="fas fa-tags"></i>
|
<div class="toggle-folders-container">
|
||||||
</button>
|
<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>
|
</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="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="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="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" 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-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords
|
updateConnectedTriggerWords
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
function mergeLoras(lorasText, lorasArr) {
|
function mergeLoras(lorasText, lorasArr) {
|
||||||
const result = [];
|
const result = [];
|
||||||
@@ -38,6 +39,45 @@ function mergeLoras(lorasText, lorasArr) {
|
|||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraLoader",
|
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) {
|
async nodeCreated(node) {
|
||||||
if (node.comfyClass === "Lora Loader (LoraManager)") {
|
if (node.comfyClass === "Lora Loader (LoraManager)") {
|
||||||
// Enable widget serialization
|
// Enable widget serialization
|
||||||
@@ -74,6 +114,12 @@ app.registerExtension({
|
|||||||
const result = addLorasWidget(node, "loras", {
|
const result = addLorasWidget(node, "loras", {
|
||||||
defaultVal: mergedLoras // Pass object directly
|
defaultVal: mergedLoras // Pass object directly
|
||||||
}, (value) => {
|
}, (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
|
// Prevent recursive calls
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
@@ -92,12 +138,6 @@ app.registerExtension({
|
|||||||
newText = newText.replace(/\s+/g, ' ').trim();
|
newText = newText.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
inputWidget.value = newText;
|
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 {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -116,12 +156,6 @@ app.registerExtension({
|
|||||||
const mergedLoras = mergeLoras(value, currentLoras);
|
const mergedLoras = mergeLoras(value, currentLoras);
|
||||||
|
|
||||||
node.lorasWidget.value = mergedLoras;
|
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 {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CONTAINER_PADDING,
|
CONTAINER_PADDING,
|
||||||
EMPTY_CONTAINER_HEIGHT
|
EMPTY_CONTAINER_HEIGHT
|
||||||
} from "./loras_widget_utils.js";
|
} 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) {
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
// Create container for loras
|
// Create container for loras
|
||||||
@@ -83,7 +83,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||||
marginBottom: "5px"
|
marginBottom: "5px",
|
||||||
|
position: "relative" // Added for positioning the drag hint
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add toggle all control
|
// Add toggle all control
|
||||||
@@ -118,7 +119,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
toggleContainer.appendChild(toggleAll);
|
toggleContainer.appendChild(toggleAll);
|
||||||
toggleContainer.appendChild(toggleLabel);
|
toggleContainer.appendChild(toggleLabel);
|
||||||
|
|
||||||
// Strength label
|
// Strength label with drag hint
|
||||||
const strengthLabel = document.createElement("div");
|
const strengthLabel = document.createElement("div");
|
||||||
strengthLabel.textContent = "Strength";
|
strengthLabel.textContent = "Strength";
|
||||||
Object.assign(strengthLabel.style, {
|
Object.assign(strengthLabel.style, {
|
||||||
@@ -129,12 +130,37 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
WebkitUserSelect: "none",
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none",
|
MozUserSelect: "none",
|
||||||
msUserSelect: "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(toggleContainer);
|
||||||
header.appendChild(strengthLabel);
|
header.appendChild(strengthLabel);
|
||||||
container.appendChild(header);
|
container.appendChild(header);
|
||||||
|
|
||||||
|
// Initialize the header drag functionality
|
||||||
|
initHeaderDrag(header, widget, renderLoras);
|
||||||
|
|
||||||
// Track the total visible entries for height calculation
|
// Track the total visible entries for height calculation
|
||||||
let totalVisibleEntries = lorasData.length;
|
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
|
// Function to initialize drag operation
|
||||||
export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) {
|
export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) {
|
||||||
let isDragging = false;
|
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
|
// Function to create context menu
|
||||||
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
||||||
// Hide preview tooltip first
|
// Hide preview tooltip first
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function collectActiveLorasFromChain(node, visited = new Set()) {
|
|||||||
// Update trigger words for connected toggle nodes
|
// Update trigger words for connected toggle nodes
|
||||||
export function updateConnectedTriggerWords(node, loraNames) {
|
export function updateConnectedTriggerWords(node, loraNames) {
|
||||||
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
|
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
|
||||||
if (connectedNodeIds.length > 0 && loraNames.size > 0) {
|
if (connectedNodeIds.length > 0) {
|
||||||
fetch("/loramanager/get_trigger_words", {
|
fetch("/loramanager/get_trigger_words", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 |