mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
9 Commits
v1.2.1
..
0a28500848
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a28500848 | |||
| fc3f3f3bdb | |||
| fa58297973 | |||
| 5d1a22fb8f | |||
| d2f50f26f1 | |||
| 4a6042d0b4 | |||
| 846206d958 | |||
| 0daf4924f0 | |||
| d38a3d091d |
+2325
-2300
File diff suppressed because it is too large
Load Diff
+27
-2
@@ -853,7 +853,8 @@
|
|||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA Recipes",
|
"title": "LoRA Recipes",
|
||||||
"actions": {
|
"actions": {
|
||||||
"sendCheckpoint": "Send to ComfyUI"
|
"sendCheckpoint": "Send to ComfyUI",
|
||||||
|
"sendRecipe": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -1532,6 +1533,30 @@
|
|||||||
"examples": "Loading examples...",
|
"examples": "Loading examples...",
|
||||||
"versions": "Loading versions..."
|
"versions": "Loading versions..."
|
||||||
},
|
},
|
||||||
|
"showcase": {
|
||||||
|
"hiddenBySfw": "{count} hidden by SFW-only setting",
|
||||||
|
"showExamples": "Show examples",
|
||||||
|
"showCount": "Show examples ({count})",
|
||||||
|
"hideExamples": "Hide examples",
|
||||||
|
"addExamples": "Add examples",
|
||||||
|
"previousExample": "Previous example",
|
||||||
|
"nextExample": "Next example",
|
||||||
|
"noExamples": "No example images available",
|
||||||
|
"addMoreExamples": "Add more examples",
|
||||||
|
"dragDrop": "Drag & drop images or videos here",
|
||||||
|
"or": "or",
|
||||||
|
"selectFiles": "Select Files",
|
||||||
|
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||||
|
"importing": "Importing files...",
|
||||||
|
"noSupportedFiles": "No supported files selected. Please select image or video files.",
|
||||||
|
"allFiltered": "All example images are filtered due to NSFW content settings",
|
||||||
|
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
|
||||||
|
"changeInSettings": "You can change this in Settings",
|
||||||
|
"nsfwMature": "Mature Content",
|
||||||
|
"nsfwR": "R-rated Content",
|
||||||
|
"nsfwX": "X-rated Content",
|
||||||
|
"nsfwXxx": "XXX-rated Content"
|
||||||
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"heading": "Model versions",
|
"heading": "Model versions",
|
||||||
"copy": "Track and manage every version of this model in one place.",
|
"copy": "Track and manage every version of this model in one place.",
|
||||||
@@ -2332,4 +2357,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
+2325
-2300
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
|
|||||||
|
|
||||||
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||||
extra folder paths, providing a unified interface for checkpoint loading.
|
extra folder paths, providing a unified interface for checkpoint loading.
|
||||||
|
The ckpt_name combo supports ComfyUI's control_after_generate, letting
|
||||||
|
users pick a random checkpoint on every run; the base_model input narrows
|
||||||
|
the random pool through a front-end extension that filters the combo
|
||||||
|
options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "Checkpoint Loader (LoraManager)"
|
NAME = "Checkpoint Loader (LoraManager)"
|
||||||
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||||
checkpoint_names = cls._get_checkpoint_names()
|
checkpoint_names = cls._get_checkpoint_names()
|
||||||
|
base_models = cls._get_available_base_models()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"ckpt_name": (
|
"ckpt_name": (
|
||||||
checkpoint_names,
|
checkpoint_names,
|
||||||
{"tooltip": "The name of the checkpoint (model) to load."},
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"The name of the checkpoint (model) to load. Use "
|
||||||
|
"control_after_generate to pick a random model on "
|
||||||
|
"every run."
|
||||||
|
),
|
||||||
|
"control_after_generate": "fixed",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"base_model": (
|
||||||
|
base_models,
|
||||||
|
{
|
||||||
|
"default": "Any",
|
||||||
|
"tooltip": (
|
||||||
|
"Restrict the random selection pool to this base "
|
||||||
|
"model. 'Any' uses the full pool."
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,15 +115,68 @@ class CheckpointLoaderLM:
|
|||||||
logger.error(f"Error getting checkpoint names: {e}")
|
logger.error(f"Error getting checkpoint names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
|
@classmethod
|
||||||
|
def _get_available_base_models(cls) -> List[str]:
|
||||||
|
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
async def _get_base_models():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
base_models = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != "checkpoint":
|
||||||
|
continue
|
||||||
|
base_model = item.get("base_model")
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if base_model and file_path and os.path.exists(file_path):
|
||||||
|
base_models.add(base_model)
|
||||||
|
|
||||||
|
return sorted(base_models)
|
||||||
|
|
||||||
|
return ["Any"] + cls._run_async(_get_base_models)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available base models: {e}")
|
||||||
|
return ["Any"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_async(coro_fn):
|
||||||
|
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(coro_fn())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro_fn())
|
||||||
|
|
||||||
|
def load_checkpoint(
|
||||||
|
self, ckpt_name: str, base_model: str = "Any"
|
||||||
|
) -> Tuple[Any, Any, Any]:
|
||||||
"""Load a checkpoint by name, supporting extra folder paths
|
"""Load a checkpoint by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||||
|
base_model: Only used by the front-end to filter the random pool
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (MODEL, CLIP, VAE)
|
Tuple of (MODEL, CLIP, VAE)
|
||||||
"""
|
"""
|
||||||
|
del base_model
|
||||||
# Get absolute path from cache using ComfyUI-style name
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -28,6 +28,10 @@ class UNETLoaderLM:
|
|||||||
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
||||||
extra folder paths, providing a unified interface for UNET loading.
|
extra folder paths, providing a unified interface for UNET loading.
|
||||||
Supports both regular diffusion models and GGUF format models.
|
Supports both regular diffusion models and GGUF format models.
|
||||||
|
The unet_name combo supports ComfyUI's control_after_generate, letting
|
||||||
|
users pick a random diffusion model on every run; the base_model input
|
||||||
|
narrows the random pool through a front-end extension that filters the
|
||||||
|
combo options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "Unet Loader (LoraManager)"
|
NAME = "Unet Loader (LoraManager)"
|
||||||
@@ -37,16 +41,34 @@ class UNETLoaderLM:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of unet names from scanner (includes extra folder paths)
|
# Get list of unet names from scanner (includes extra folder paths)
|
||||||
unet_names = cls._get_unet_names()
|
unet_names = cls._get_unet_names()
|
||||||
|
base_models = cls._get_available_base_models()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"unet_name": (
|
"unet_name": (
|
||||||
unet_names,
|
unet_names,
|
||||||
{"tooltip": "The name of the diffusion model to load."},
|
{
|
||||||
|
"tooltip": (
|
||||||
|
"The name of the diffusion model to load. Use "
|
||||||
|
"control_after_generate to pick a random model on "
|
||||||
|
"every run."
|
||||||
|
),
|
||||||
|
"control_after_generate": "fixed",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
"weight_dtype": (
|
"weight_dtype": (
|
||||||
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||||
{"tooltip": "The dtype to use for the model weights."},
|
{"tooltip": "The dtype to use for the model weights."},
|
||||||
),
|
),
|
||||||
|
"base_model": (
|
||||||
|
base_models,
|
||||||
|
{
|
||||||
|
"default": "Any",
|
||||||
|
"tooltip": (
|
||||||
|
"Restrict the random selection pool to this base "
|
||||||
|
"model. 'Any' uses the full pool."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +130,69 @@ class UNETLoaderLM:
|
|||||||
logger.error(f"Error getting unet names: {e}")
|
logger.error(f"Error getting unet names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
|
@classmethod
|
||||||
|
def _get_available_base_models(cls) -> List[str]:
|
||||||
|
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
|
||||||
|
try:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
async def _get_base_models():
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
base_models = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != "diffusion_model":
|
||||||
|
continue
|
||||||
|
base_model = item.get("base_model")
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if base_model and file_path and os.path.exists(file_path):
|
||||||
|
base_models.add(base_model)
|
||||||
|
|
||||||
|
return sorted(base_models)
|
||||||
|
|
||||||
|
return ["Any"] + cls._run_async(_get_base_models)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available base models: {e}")
|
||||||
|
return ["Any"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_async(coro_fn):
|
||||||
|
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(coro_fn())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro_fn())
|
||||||
|
|
||||||
|
def load_unet(
|
||||||
|
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
|
||||||
|
) -> Tuple[Any, ...]:
|
||||||
"""Load a diffusion model by name, supporting extra folder paths
|
"""Load a diffusion model by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
unet_name: The name of the diffusion model to load (relative path with extension)
|
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||||
weight_dtype: The dtype to use for model weights
|
weight_dtype: The dtype to use for model weights
|
||||||
|
base_model: Only used by the front-end to filter the random pool
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (MODEL,)
|
Tuple of (MODEL,)
|
||||||
"""
|
"""
|
||||||
|
del base_model
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
# Get absolute path from cache using ComfyUI-style name
|
# Get absolute path from cache using ComfyUI-style name
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Any, Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
|
|||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
from ..utils.utils import _format_model_name_for_comfyui
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,7 +46,45 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
# Checkpoint roots and Unet roots
|
# Checkpoint roots and Unet roots
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
||||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
||||||
|
|
||||||
|
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
|
||||||
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
|
||||||
|
|
||||||
|
async def get_loader_pool(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return ComfyUI-formatted model names with their base_model.
|
||||||
|
|
||||||
|
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
|
||||||
|
filters the ckpt_name/unet_name combo options by base_model using this
|
||||||
|
pool, so control_after_generate randomizes within the narrowed set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sub_type = request.query.get("sub_type", "checkpoint")
|
||||||
|
if sub_type not in ("checkpoint", "diffusion_model"):
|
||||||
|
return web.json_response({"error": "invalid sub_type"}, status=400)
|
||||||
|
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
model_roots = scanner.get_model_roots()
|
||||||
|
items: List[Dict[str, str]] = []
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if item.get("sub_type") != sub_type:
|
||||||
|
continue
|
||||||
|
file_path = item.get("file_path", "")
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
continue
|
||||||
|
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||||
|
if formatted_name:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"name": formatted_name,
|
||||||
|
"base_model": item.get("base_model", "") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.sort(key=lambda x: x["name"])
|
||||||
|
return web.json_response({"items": items})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting loader pool: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
"""Validate CivitAI model type for Checkpoint"""
|
"""Validate CivitAI model type for Checkpoint"""
|
||||||
return model_type.lower() == 'checkpoint'
|
return model_type.lower() == 'checkpoint'
|
||||||
|
|||||||
@@ -3590,9 +3590,6 @@ class RecipeScanner:
|
|||||||
|
|
||||||
syntax_parts: List[str] = []
|
syntax_parts: List[str] = []
|
||||||
for lora in loras:
|
for lora in loras:
|
||||||
if lora.get("isDeleted", False):
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
folder = ""
|
folder = ""
|
||||||
hash_value = (lora.get("hash") or "").lower()
|
hash_value = (lora.get("hash") or "").lower()
|
||||||
@@ -3627,6 +3624,8 @@ class RecipeScanner:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not file_name:
|
if not file_name:
|
||||||
|
if lora.get("isDeleted", False):
|
||||||
|
continue
|
||||||
file_name = lora.get("file_name", "unknown-lora")
|
file_name = lora.get("file_name", "unknown-lora")
|
||||||
folder = lora.get("folder", "")
|
folder = lora.get("folder", "")
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/* Blurred backdrop for the model modal to match the recipe modal */
|
||||||
|
#modelModal {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Lora Modal Header */
|
/* Lora Modal Header */
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,19 +4,268 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel {
|
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
|
||||||
transition: max-height 0.3s ease-in-out;
|
|
||||||
|
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
|
||||||
|
.gallery-indicator-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-preview-thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-preview-thumb img,
|
||||||
|
.gallery-preview-thumb video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-indicator-bar .gallery-show-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-indicator-bar .gallery-import-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded gallery toolbar */
|
||||||
|
.gallery-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position badge floats over the main media, bottom-right */
|
||||||
|
.gallery-position-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--space-2);
|
||||||
|
bottom: var(--space-2);
|
||||||
|
z-index: 6;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While the gallery is expanded the thumbnail strip sits in the modal's
|
||||||
|
bottom-right corner, where the back-to-top button would overlap it */
|
||||||
|
.modal-content.showcase-expanded .back-to-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-toolbar .gallery-import-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-show-btn,
|
||||||
|
.gallery-import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-show-btn:hover,
|
||||||
|
.gallery-import-btn:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-filter-notification {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main viewer — the container hugs the active media's aspect ratio
|
||||||
|
(--media-aspect = width/height, set per item) so no dead space remains.
|
||||||
|
overflow: hidden also clips the hoisted metadata panel while it is
|
||||||
|
translated below the bottom edge, so it never extends the modal's
|
||||||
|
scrollable height (which caused a scroll jump when it appeared) */
|
||||||
|
.gallery-main {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-media-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
|
||||||
|
aspect-ratio: var(--media-aspect, 1.3333);
|
||||||
|
max-height: min(75vh, 800px);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel.collapsed {
|
.main-media-container .media-wrapper {
|
||||||
max-height: 0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-container {
|
.main-media-container .media-wrapper img,
|
||||||
|
.main-media-container .media-wrapper video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav buttons float over the media, visible on hover */
|
||||||
|
.gallery-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav.prev {
|
||||||
|
left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav.next {
|
||||||
|
right: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-main:hover .gallery-nav,
|
||||||
|
.gallery-nav:focus-visible {
|
||||||
|
opacity: 0.9;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-nav:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail strip */
|
||||||
|
.gallery-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: var(--space-1);
|
||||||
gap: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb:hover {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb.active {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-media.blurred {
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-video-badge,
|
||||||
|
.gallery-thumb .thumb-nsfw-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
right: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 1px 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-thumb .thumb-nsfw-badge {
|
||||||
|
top: 3px;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-strip::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-strip::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline import zone toggled from the toolbar */
|
||||||
|
.gallery-import-zone {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-import-zone.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-import-zone .example-import-area {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper {
|
.media-wrapper {
|
||||||
@@ -31,16 +280,6 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper img,
|
|
||||||
.media-wrapper video {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-examples {
|
.no-examples {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
@@ -48,11 +287,6 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust the media wrapper for tab system */
|
|
||||||
#showcase-tab .carousel-container {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add styles for blurred showcase content */
|
/* Add styles for blurred showcase content */
|
||||||
.nsfw-media-wrapper {
|
.nsfw-media-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -217,6 +451,24 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
|
||||||
|
.gallery-main > .image-metadata-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 7;
|
||||||
|
max-height: 60%;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-main > .image-metadata-panel.visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.98;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Adjust to dark theme */
|
/* Adjust to dark theme */
|
||||||
[data-theme="dark"] .image-metadata-panel {
|
[data-theme="dark"] .image-metadata-panel {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -388,31 +640,6 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scroll Indicator */
|
|
||||||
.scroll-indicator {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--lora-surface);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
transition: background-color 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-indicator:hover {
|
|
||||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-indicator span {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lazy {
|
.lazy {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
|
|||||||
@@ -104,15 +104,29 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Darker, blurred backdrop keeps the busy page behind the modal from bleeding through */
|
||||||
|
#recipeModal {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
#recipeModal .modal-content {
|
#recipeModal .modal-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
|
||||||
|
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
|
||||||
|
width: min(1600px, 94vw);
|
||||||
|
max-width: min(1600px, 94vw);
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#recipeModal .modal-body {
|
#recipeModal .modal-body {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 320px minmax(0, 1fr) 420px;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -174,19 +188,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Section: Preview and Gen Params */
|
/* Left Column: Preview */
|
||||||
.recipe-top-section {
|
.recipe-media-column {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 280px 1fr;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-shrink: 0;
|
min-height: 0;
|
||||||
margin-bottom: var(--space-2);
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Preview */
|
/* Recipe Preview */
|
||||||
.recipe-preview-container {
|
.recipe-preview-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 360px;
|
box-sizing: border-box; /* Keep the 1px border inside the column width */
|
||||||
|
height: auto;
|
||||||
|
max-height: 42vh;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
@@ -196,18 +213,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-preview-container img,
|
.recipe-preview-container img,
|
||||||
.recipe-preview-container video {
|
.recipe-preview-container video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 42vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-preview-media {
|
.recipe-preview-media {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 42vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,9 +358,10 @@
|
|||||||
|
|
||||||
/* Generation Parameters */
|
/* Generation Parameters */
|
||||||
.recipe-gen-params {
|
.recipe-gen-params {
|
||||||
height: 360px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-params-header-row {
|
.gen-params-header-row {
|
||||||
@@ -399,8 +418,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-group {
|
.param-group {
|
||||||
@@ -453,8 +470,6 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -526,14 +541,12 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom Section: Resources */
|
/* Right Column: Resources */
|
||||||
.recipe-bottom-section {
|
.recipe-bottom-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-section-header {
|
.recipe-section-header {
|
||||||
@@ -1010,18 +1023,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1500px) {
|
||||||
.recipe-top-section {
|
#recipeModal .modal-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 300px minmax(0, 1fr) 380px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.recipe-preview-container {
|
|
||||||
height: 200px;
|
@media (max-width: 1000px) {
|
||||||
|
#recipeModal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-media-column {
|
||||||
|
overflow-y: visible;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container,
|
||||||
|
.recipe-preview-container img,
|
||||||
|
.recipe-preview-container video,
|
||||||
|
.recipe-preview-media {
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-gen-params {
|
.recipe-gen-params {
|
||||||
height: auto;
|
overflow-y: visible;
|
||||||
max-height: 300px;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-bottom-section {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-loras-list {
|
||||||
|
max-height: 45vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1045,19 +1083,11 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-top-section {
|
.recipe-preview-container,
|
||||||
grid-template-columns: 1fr;
|
.recipe-preview-container img,
|
||||||
gap: var(--space-1);
|
.recipe-preview-container video,
|
||||||
margin-bottom: var(--space-1);
|
.recipe-preview-media {
|
||||||
}
|
max-height: 32vh;
|
||||||
|
|
||||||
.recipe-preview-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-gen-params {
|
|
||||||
height: auto;
|
|
||||||
max-height: 210px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-gen-params h3 {
|
.recipe-gen-params h3 {
|
||||||
@@ -1070,7 +1100,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.param-content {
|
.param-content {
|
||||||
max-height: 90px;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,10 +1112,6 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-bottom-section {
|
|
||||||
padding-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-section-header {
|
.recipe-section-header {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ class RecipeModal {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsContainer.innerHTML = '';
|
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
|
||||||
|
|
||||||
const sourcePath = this.currentRecipe?.source_path || '';
|
const sourcePath = this.currentRecipe?.source_path || '';
|
||||||
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||||
@@ -719,7 +719,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||||
@@ -1180,11 +1180,10 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup copy buttons for prompts and recipe syntax
|
// Setup copy buttons for prompts and send recipe button
|
||||||
setupCopyButtons() {
|
setupCopyButtons() {
|
||||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
|
||||||
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
@@ -1207,13 +1206,6 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyRecipeSyntaxBtn) {
|
|
||||||
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
|
||||||
// Use backend API to get recipe syntax
|
|
||||||
this.fetchAndCopyRecipeSyntax();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sendRecipeBtn) {
|
if (sendRecipeBtn) {
|
||||||
sendRecipeBtn.addEventListener('click', () => {
|
sendRecipeBtn.addEventListener('click', () => {
|
||||||
// Send recipe to ComfyUI workflow
|
// Send recipe to ComfyUI workflow
|
||||||
@@ -1299,35 +1291,6 @@ class RecipeModal {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recipe syntax from backend and copy to clipboard
|
|
||||||
async fetchAndCopyRecipeSyntax() {
|
|
||||||
if (!this.recipeId) {
|
|
||||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch recipe syntax from backend
|
|
||||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.syntax) {
|
|
||||||
// Use the centralized copyToClipboard utility function
|
|
||||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'No syntax returned from server');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recipe syntax:', error);
|
|
||||||
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to copy text to clipboard
|
// Helper method to copy text to clipboard
|
||||||
copyToClipboard(text, successMessage) {
|
copyToClipboard(text, successMessage) {
|
||||||
copyToClipboard(text, successMessage);
|
copyToClipboard(text, successMessage);
|
||||||
@@ -1632,7 +1595,7 @@ class RecipeModal {
|
|||||||
let headerAction = '';
|
let headerAction = '';
|
||||||
if (existsLocally && localPath) {
|
if (existsLocally && localPath) {
|
||||||
headerAction = `
|
headerAction = `
|
||||||
<button class="resource-action primary compact checkpoint-send">
|
<button class="resource-action compact checkpoint-send">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { bulkManager } from '../managers/BulkManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||||
|
|
||||||
export class SidebarManager {
|
export class SidebarManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -252,6 +253,9 @@ export class SidebarManager {
|
|||||||
if (dataTransfer) {
|
if (dataTransfer) {
|
||||||
dataTransfer.effectAllowed = 'move';
|
dataTransfer.effectAllowed = 'move';
|
||||||
dataTransfer.setData('text/plain', filePaths.join(','));
|
dataTransfer.setData('text/plain', filePaths.join(','));
|
||||||
|
// Tag the drag as an internal card drag so preview-drop handlers on
|
||||||
|
// other cards ignore it (no highlight, no preview replacement).
|
||||||
|
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
|
||||||
try {
|
try {
|
||||||
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
|
|||||||
|
|
||||||
const keyHandler = (e) => {
|
const keyHandler = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// Stop propagation so bubble-phase handlers (e.g. ModalManager's
|
||||||
|
// Escape handler) do not also close the modal underneath.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
closeMediaViewer();
|
closeMediaViewer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
@@ -304,10 +303,20 @@ function handleCardClick(card, modelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview URL is not in the dataset; read it from the card's rendered media
|
||||||
|
function getCardPreviewUrl(card) {
|
||||||
|
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
|
||||||
|
if (!cardMedia) return '';
|
||||||
|
return cardMedia.tagName === 'VIDEO'
|
||||||
|
? (cardMedia.dataset.src || '')
|
||||||
|
: (cardMedia.src || '');
|
||||||
|
}
|
||||||
|
|
||||||
async function showModelModalFromCard(card, modelType) {
|
async function showModelModalFromCard(card, modelType) {
|
||||||
// Create model metadata object
|
// Create model metadata object
|
||||||
const modelMeta = {
|
const modelMeta = {
|
||||||
sha256: card.dataset.sha256,
|
sha256: card.dataset.sha256,
|
||||||
|
preview_url: getCardPreviewUrl(card),
|
||||||
file_path: card.dataset.filepath,
|
file_path: card.dataset.filepath,
|
||||||
model_name: card.dataset.name,
|
model_name: card.dataset.name,
|
||||||
file_name: card.dataset.file_name,
|
file_name: card.dataset.file_name,
|
||||||
@@ -397,6 +406,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||||
const modelMeta = {
|
const modelMeta = {
|
||||||
sha256: card.dataset.sha256,
|
sha256: card.dataset.sha256,
|
||||||
|
preview_url: getCardPreviewUrl(card),
|
||||||
file_path: card.dataset.filepath,
|
file_path: card.dataset.filepath,
|
||||||
model_name: card.dataset.name,
|
model_name: card.dataset.name,
|
||||||
file_name: card.dataset.file_name,
|
file_name: card.dataset.file_name,
|
||||||
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Show the model modal
|
// Show the model modal
|
||||||
await showModelModal(modelMeta, modelType);
|
await showModelModal(modelMeta, modelType);
|
||||||
|
|
||||||
// Scroll to import area after modal is visible
|
// Reveal the import entry once the modal content has rendered
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const importArea = document.querySelector('.example-import-area');
|
// Gallery mode: the import button is always visible — expand the zone
|
||||||
|
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
|
||||||
|
if (importBtn) {
|
||||||
|
importBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Empty state: the import area is the whole tab content — scroll to it
|
||||||
|
const importArea = document.querySelector('#modelModal .example-import-area');
|
||||||
if (importArea) {
|
if (importArea) {
|
||||||
const showcaseTab = document.getElementById('showcase-tab');
|
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
if (showcaseTab) {
|
|
||||||
// First make sure showcase tab is visible
|
|
||||||
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
|
|
||||||
if (tabBtn && !tabBtn.classList.contains('active')) {
|
|
||||||
tabBtn.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then toggle showcase if collapsed
|
|
||||||
const carousel = showcaseTab.querySelector('.carousel');
|
|
||||||
if (carousel && carousel.classList.contains('collapsed')) {
|
|
||||||
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
|
||||||
if (scrollIndicator) {
|
|
||||||
toggleShowcase(scrollIndicator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally scroll to the import area
|
|
||||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
@@ -457,6 +455,9 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
export function createModelCard(model, modelType) {
|
export function createModelCard(model, modelType) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'model-card'; // Reuse the same class for styling
|
card.className = 'model-card'; // Reuse the same class for styling
|
||||||
|
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
|
||||||
|
// from click jitter are rendered harmless by the preview-drop handlers
|
||||||
|
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
|
||||||
card.draggable = true;
|
card.draggable = true;
|
||||||
card.dataset.sha256 = model.sha256;
|
card.dataset.sha256 = model.sha256;
|
||||||
card.dataset.filepath = model.file_path;
|
card.dataset.filepath = model.file_path;
|
||||||
@@ -649,7 +650,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
@@ -743,6 +744,11 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
// Dropping an image/video onto the card replaces the model preview via the
|
// Dropping an image/video onto the card replaces the model preview via the
|
||||||
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
||||||
|
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
|
||||||
|
// SidebarManager and must be ignored here entirely: no highlight, no upload.
|
||||||
|
const isInternalCardDrag = (event) =>
|
||||||
|
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
|
||||||
|
|
||||||
const preventDragDefaults = (event) => {
|
const preventDragDefaults = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -750,17 +756,20 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
['dragenter', 'dragover'].forEach((eventName) => {
|
['dragenter', 'dragover'].forEach((eventName) => {
|
||||||
card.addEventListener(eventName, (event) => {
|
card.addEventListener(eventName, (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.add('drag-over');
|
card.classList.add('drag-over');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addEventListener('dragleave', (event) => {
|
card.addEventListener('dragleave', (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.remove('drag-over');
|
card.classList.remove('drag-over');
|
||||||
});
|
});
|
||||||
|
|
||||||
card.addEventListener('drop', (event) => {
|
card.addEventListener('drop', (event) => {
|
||||||
|
if (isInternalCardDrag(event)) return;
|
||||||
preventDragDefaults(event);
|
preventDragDefaults(event);
|
||||||
card.classList.remove('drag-over');
|
card.classList.remove('drag-over');
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
|
|||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import {
|
import {
|
||||||
toggleShowcase,
|
|
||||||
setupShowcaseScroll,
|
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
loadExampleImages
|
loadExampleImages
|
||||||
} from './showcase/ShowcaseView.js';
|
} from './showcase/ShowcaseView.js';
|
||||||
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
updateCardUpdateAvailability(hasUpdate);
|
updateCardUpdateAvailability(hasUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
let showcaseCleanup;
|
|
||||||
|
|
||||||
const onCloseCallback = function () {
|
const onCloseCallback = function () {
|
||||||
// Clean up all handlers when modal closes for LoRA
|
// Clean up all handlers when modal closes for LoRA
|
||||||
const modalElement = document.getElementById(modalId);
|
const modalElement = document.getElementById(modalId);
|
||||||
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||||
delete modalElement._clickHandler;
|
delete modalElement._clickHandler;
|
||||||
}
|
}
|
||||||
if (showcaseCleanup) {
|
|
||||||
showcaseCleanup();
|
|
||||||
showcaseCleanup = null;
|
|
||||||
}
|
|
||||||
cleanupNavigationShortcuts();
|
cleanupNavigationShortcuts();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
||||||
activeModalElement.dataset.folder = modelWithFullData.folder;
|
activeModalElement.dataset.folder = modelWithFullData.folder;
|
||||||
}
|
}
|
||||||
|
// Show the back-to-top button once the modal content is scrolled
|
||||||
|
const modalContent = activeModalElement.querySelector('.modal-content');
|
||||||
|
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
|
||||||
|
if (modalContent && backToTopBtn) {
|
||||||
|
modalContent.addEventListener('scroll', () => {
|
||||||
|
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
||||||
const versionsTabController = initVersionsTab({
|
const versionsTabController = initVersionsTab({
|
||||||
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
|
|||||||
onUpdateStatusChange: handleUpdateStatusChange,
|
onUpdateStatusChange: handleUpdateStatusChange,
|
||||||
});
|
});
|
||||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||||
showcaseCleanup = setupShowcaseScroll(modalId);
|
|
||||||
setupTabSwitching({
|
setupTabSwitching({
|
||||||
onTabChange: async (tab) => {
|
onTabChange: async (tab) => {
|
||||||
if (tab === 'versions') {
|
if (tab === 'versions') {
|
||||||
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||||
// Combine images - regular images first, then custom images
|
// Combine images - regular images first, then custom images
|
||||||
const allImages = [...regularImages, ...customImages];
|
const allImages = [...regularImages, ...customImages];
|
||||||
loadExampleImages(allImages, modelWithFullData.sha256);
|
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLoraSpecificContent(lora, escapedWords) {
|
function renderLoraSpecificContent(lora, escapedWords) {
|
||||||
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
|
|||||||
// Export the model modal API
|
// Export the model modal API
|
||||||
const modelModal = {
|
const modelModal = {
|
||||||
show: showModelModal,
|
show: showModelModal,
|
||||||
toggleShowcase,
|
|
||||||
scrollToTop
|
scrollToTop
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate video wrapper HTML
|
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
|
||||||
|
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||||
* @param {Object} media - Media metadata
|
* @param {Object} media - Media metadata
|
||||||
* @param {number} heightPercent - Height percentage for container
|
|
||||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||||
* @param {string} nsfwText - NSFW warning text
|
* @param {string} nsfwText - NSFW warning text
|
||||||
* @param {string} metadataPanel - Metadata panel HTML
|
* @param {string} metadataPanel - Metadata panel HTML
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate image wrapper HTML
|
* Generate image wrapper HTML. The wrapper fills its container (the gallery's
|
||||||
|
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||||
* @param {Object} media - Media metadata
|
* @param {Object} media - Media metadata
|
||||||
* @param {number} heightPercent - Height percentage for container
|
|
||||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||||
* @param {string} nsfwText - NSFW warning text
|
* @param {string} nsfwText - NSFW warning text
|
||||||
* @param {string} metadataPanel - Metadata panel HTML
|
* @param {string} metadataPanel - Metadata panel HTML
|
||||||
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
|||||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
|
|||||||
@@ -213,190 +213,170 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize metadata panel interaction handlers
|
* Initialize metadata panel interaction handlers: hover over the media reveals
|
||||||
|
* the panel and media controls (same as the legacy carousel). Panel-internal
|
||||||
|
* buttons and wheel isolation are bound here as well.
|
||||||
* @param {HTMLElement} container - Container element with media wrappers
|
* @param {HTMLElement} container - Container element with media wrappers
|
||||||
*/
|
*/
|
||||||
export function initMetadataPanelHandlers(container) {
|
export function initMetadataPanelHandlers(container) {
|
||||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||||
|
|
||||||
mediaWrappers.forEach(wrapper => {
|
mediaWrappers.forEach(wrapper => {
|
||||||
// Get the metadata panel and media element (img or video)
|
|
||||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||||
|
if (!metadataPanel) return;
|
||||||
|
|
||||||
const mediaControls = wrapper.querySelector('.media-controls');
|
const mediaControls = wrapper.querySelector('.media-controls');
|
||||||
const mediaElement = wrapper.querySelector('img, video');
|
const mediaElement = wrapper.querySelector('img, video');
|
||||||
|
|
||||||
if (!mediaElement) return;
|
if (mediaElement) {
|
||||||
|
let isOverMetadataPanel = false;
|
||||||
let isOverMetadataPanel = false;
|
|
||||||
|
// Hovering the actual media content reveals the metadata panel and controls
|
||||||
// Add event listeners to the wrapper for mouse tracking
|
wrapper.addEventListener('mousemove', (e) => {
|
||||||
wrapper.addEventListener('mousemove', (e) => {
|
const rect = wrapper.getBoundingClientRect();
|
||||||
// Get mouse position relative to wrapper
|
const mouseX = e.clientX - rect.left;
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const mouseY = e.clientY - rect.top;
|
||||||
const mouseX = e.clientX - rect.left;
|
|
||||||
const mouseY = e.clientY - rect.top;
|
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||||
|
const isOverMedia = (
|
||||||
// Get the actual displayed dimensions of the media element
|
mouseX >= mediaRect.left &&
|
||||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
mouseX <= mediaRect.right &&
|
||||||
|
mouseY >= mediaRect.top &&
|
||||||
// Check if mouse is over the actual media content
|
mouseY <= mediaRect.bottom
|
||||||
const isOverMedia = (
|
);
|
||||||
mouseX >= mediaRect.left &&
|
|
||||||
mouseX <= mediaRect.right &&
|
if (isOverMedia || isOverMetadataPanel) {
|
||||||
mouseY >= mediaRect.top &&
|
metadataPanel.classList.add('visible');
|
||||||
mouseY <= mediaRect.bottom
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
);
|
} else {
|
||||||
|
metadataPanel.classList.remove('visible');
|
||||||
// Show metadata panel and controls when over media content or metadata panel itself
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
if (isOverMedia || isOverMetadataPanel) {
|
}
|
||||||
if (metadataPanel) metadataPanel.classList.add('visible');
|
});
|
||||||
if (mediaControls) mediaControls.classList.add('visible');
|
|
||||||
} else {
|
wrapper.addEventListener('mouseleave', () => {
|
||||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
if (!isOverMetadataPanel) {
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
metadataPanel.classList.remove('visible');
|
||||||
}
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
});
|
}
|
||||||
|
});
|
||||||
wrapper.addEventListener('mouseleave', () => {
|
|
||||||
if (!isOverMetadataPanel) {
|
|
||||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add mouse enter/leave events for the metadata panel itself
|
|
||||||
if (metadataPanel) {
|
|
||||||
metadataPanel.addEventListener('mouseenter', () => {
|
metadataPanel.addEventListener('mouseenter', () => {
|
||||||
isOverMetadataPanel = true;
|
isOverMetadataPanel = true;
|
||||||
metadataPanel.classList.add('visible');
|
metadataPanel.classList.add('visible');
|
||||||
if (mediaControls) mediaControls.classList.add('visible');
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
metadataPanel.addEventListener('mouseleave', () => {
|
metadataPanel.addEventListener('mouseleave', () => {
|
||||||
isOverMetadataPanel = false;
|
isOverMetadataPanel = false;
|
||||||
// Only hide if mouse is not over the media
|
metadataPanel.classList.remove('visible');
|
||||||
const rect = wrapper.getBoundingClientRect();
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
|
||||||
const mouseX = event.clientX - rect.left;
|
|
||||||
const mouseY = event.clientY - rect.top;
|
|
||||||
|
|
||||||
const isOverMedia = (
|
|
||||||
mouseX >= mediaRect.left &&
|
|
||||||
mouseX <= mediaRect.right &&
|
|
||||||
mouseY >= mediaRect.top &&
|
|
||||||
mouseY <= mediaRect.bottom
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isOverMedia) {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
if (mediaControls) mediaControls.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prevent events from bubbling
|
|
||||||
metadataPanel.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle copy prompt buttons
|
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
|
||||||
copyBtns.forEach(copyBtn => {
|
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle send prompt buttons
|
|
||||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
|
||||||
sendBtns.forEach(sendBtn => {
|
|
||||||
const promptIndex = sendBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
sendBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
let promptText = promptElement.textContent || '';
|
|
||||||
if (!promptText.trim()) {
|
|
||||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect strip <lora> setting from global state
|
|
||||||
if (state.global.settings?.strip_lora_on_copy) {
|
|
||||||
promptText = stripLoraTags(promptText);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPromptToWorkflow(promptText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle send params buttons
|
|
||||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
|
||||||
if (paramsBtn) {
|
|
||||||
paramsBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Collect gen params from the param-tag elements
|
|
||||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
|
||||||
if (!tagsContainer) return;
|
|
||||||
|
|
||||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
|
||||||
const genParams = {};
|
|
||||||
|
|
||||||
// Map display labels to genParams keys
|
|
||||||
const labelToKey = {
|
|
||||||
'Seed': 'seed',
|
|
||||||
'Steps': 'steps',
|
|
||||||
'Sampler': 'sampler',
|
|
||||||
'CFG': 'cfg_scale',
|
|
||||||
};
|
|
||||||
|
|
||||||
paramTags.forEach(tag => {
|
|
||||||
const nameEl = tag.querySelector('.param-name');
|
|
||||||
const valueEl = tag.querySelector('.param-value');
|
|
||||||
if (!nameEl || !valueEl) return;
|
|
||||||
|
|
||||||
const label = nameEl.textContent.replace(':', '').trim();
|
|
||||||
const key = labelToKey[label];
|
|
||||||
if (key) {
|
|
||||||
genParams[key] = valueEl.textContent.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(genParams).length === 0) {
|
|
||||||
showToast('No sendable parameters found', {}, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendGenParamsToWorkflow(genParams);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent panel scroll from causing modal scroll
|
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
|
||||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
|
||||||
|
|
||||||
// Only prevent default if scrolling would cause the panel to scroll
|
|
||||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent events from bubbling
|
||||||
|
metadataPanel.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle copy prompt buttons
|
||||||
|
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||||
|
copyBtns.forEach(copyBtn => {
|
||||||
|
const promptIndex = copyBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle send prompt buttons
|
||||||
|
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||||
|
sendBtns.forEach(sendBtn => {
|
||||||
|
const promptIndex = sendBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
let promptText = promptElement.textContent || '';
|
||||||
|
if (!promptText.trim()) {
|
||||||
|
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect strip <lora> setting from global state
|
||||||
|
if (state.global.settings?.strip_lora_on_copy) {
|
||||||
|
promptText = stripLoraTags(promptText);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPromptToWorkflow(promptText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle send params buttons
|
||||||
|
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||||
|
if (paramsBtn) {
|
||||||
|
paramsBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Collect gen params from the param-tag elements
|
||||||
|
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||||
|
const genParams = {};
|
||||||
|
|
||||||
|
// Map display labels to genParams keys
|
||||||
|
const labelToKey = {
|
||||||
|
'Seed': 'seed',
|
||||||
|
'Steps': 'steps',
|
||||||
|
'Sampler': 'sampler',
|
||||||
|
'CFG': 'cfg_scale',
|
||||||
|
};
|
||||||
|
|
||||||
|
paramTags.forEach(tag => {
|
||||||
|
const nameEl = tag.querySelector('.param-name');
|
||||||
|
const valueEl = tag.querySelector('.param-value');
|
||||||
|
if (!nameEl || !valueEl) return;
|
||||||
|
|
||||||
|
const label = nameEl.textContent.replace(':', '').trim();
|
||||||
|
const key = labelToKey[label];
|
||||||
|
if (key) {
|
||||||
|
genParams[key] = valueEl.textContent.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(genParams).length === 0) {
|
||||||
|
showToast('No sendable parameters found', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendGenParamsToWorkflow(genParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent panel scroll from causing modal scroll
|
||||||
|
metadataPanel.addEventListener('wheel', (e) => {
|
||||||
|
const isAtTop = metadataPanel.scrollTop === 0;
|
||||||
|
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||||
|
|
||||||
|
// Only prevent default if scrolling would cause the panel to scroll
|
||||||
|
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
|
||||||
|
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { shortId }
|
||||||
|
}));
|
||||||
|
|
||||||
// Success: remove the media wrapper from the DOM
|
// Success: remove the media wrapper from the DOM
|
||||||
mediaWrapper.style.opacity = '0';
|
mediaWrapper.style.opacity = '0';
|
||||||
mediaWrapper.style.height = '0';
|
mediaWrapper.style.height = '0';
|
||||||
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
// Initialize NSFW level buttons
|
// Initialize NSFW level buttons
|
||||||
initSetNsfwHandlers(container);
|
initSetNsfwHandlers(container);
|
||||||
|
|
||||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
// Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
|
||||||
// Any click handlers or other functionality can still be added here
|
// Any click handlers or other functionality can still be added here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
|
|||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
|
||||||
|
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
|
||||||
|
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
|
||||||
|
|
||||||
// Model sub-type display names (new canonical field: sub_type)
|
// Model sub-type display names (new canonical field: sub_type)
|
||||||
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
||||||
// LoRA sub-types
|
// LoRA sub-types
|
||||||
|
|||||||
@@ -4,21 +4,27 @@
|
|||||||
|
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
|
||||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Top Section: Preview and Generation Parameters -->
|
<!-- Left Column: Preview -->
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
|
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
|
||||||
|
<!-- Center Column: Generation Parameters -->
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-header-row">
|
<div class="gen-params-header-row">
|
||||||
<h3>Generation Parameters</h3>
|
<h3>Generation Parameters</h3>
|
||||||
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||||
@@ -103,9 +109,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Right Column: Resources -->
|
||||||
<!-- Bottom Section: Resources -->
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -114,12 +119,6 @@
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
|
||||||
<i class="fas fa-paper-plane"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-resources-list">
|
<div class="recipe-resources-list">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
|
||||||
|
const MEDIA_VIEWER_MODULE = new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname;
|
||||||
|
|
||||||
|
function setupDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="modelModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<img class="media-wrapper" src="" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediaViewer Escape handling', () => {
|
||||||
|
let ModalManager;
|
||||||
|
let manager;
|
||||||
|
let openMediaViewer;
|
||||||
|
let isMediaViewerOpen;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
setupDom();
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
|
||||||
|
manager = new ModalManager();
|
||||||
|
manager.initialize();
|
||||||
|
({ openMediaViewer, isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes only the media viewer, not the underlying modal, on Escape', () => {
|
||||||
|
manager.showModal('modelModal');
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||||
|
|
||||||
|
openMediaViewer('https://example.com/image.png');
|
||||||
|
expect(isMediaViewerOpen()).toBe(true);
|
||||||
|
|
||||||
|
// Dispatch on document.body (real keydown target is the focused element,
|
||||||
|
// never document itself) so the capture handler fires before the bubble one.
|
||||||
|
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(isMediaViewerOpen()).toBe(false);
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets Escape close the modal when no viewer is open', () => {
|
||||||
|
manager.showModal('modelModal');
|
||||||
|
|
||||||
|
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
|
||||||
|
expect(manager.getModal('modelModal').isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -246,13 +246,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -284,7 +290,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -293,9 +298,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -370,13 +372,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -408,7 +416,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -417,9 +424,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -464,13 +468,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -502,7 +512,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -511,9 +520,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -573,13 +579,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -611,7 +623,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -620,9 +631,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -662,13 +670,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -700,7 +714,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -709,9 +722,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -765,13 +775,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -803,7 +819,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -812,9 +827,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -885,13 +897,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -923,7 +941,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -932,9 +949,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1019,13 +1033,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1057,7 +1077,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1068,9 +1087,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1138,7 +1154,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeLorasList"></div>
|
<div id="recipeLorasList"></div>
|
||||||
<span id="recipeLorasCount"></span>
|
<span id="recipeLorasCount"></span>
|
||||||
<button id="viewRecipeLorasBtn"></button>
|
<button id="viewRecipeLorasBtn"></button>
|
||||||
<button id="copyRecipeSyntaxBtn"></button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1191,7 +1207,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeLorasList"></div>
|
<div id="recipeLorasList"></div>
|
||||||
<span id="recipeLorasCount"></span>
|
<span id="recipeLorasCount"></span>
|
||||||
<button id="viewRecipeLorasBtn"></button>
|
<button id="viewRecipeLorasBtn"></button>
|
||||||
<button id="copyRecipeSyntaxBtn"></button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1255,13 +1271,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1293,7 +1315,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1304,9 +1325,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1368,13 +1386,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1406,7 +1430,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div id="recipeCheckpoint"></div>
|
<div id="recipeCheckpoint"></div>
|
||||||
<div id="recipeResourceDivider"></div>
|
<div id="recipeResourceDivider"></div>
|
||||||
@@ -1417,9 +1440,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1486,13 +1506,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1524,7 +1550,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1533,9 +1558,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1594,13 +1616,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1632,7 +1660,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1641,9 +1668,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1711,13 +1735,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1749,7 +1779,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1758,9 +1787,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
@@ -1808,13 +1834,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1846,7 +1878,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
@@ -1932,13 +1963,19 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="recipeTagsContainer"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-media-column">
|
||||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<div class="param-group info-item">
|
<div class="param-group info-item">
|
||||||
@@ -1970,7 +2007,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="other-params" id="recipeOtherParams"></div>
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="info-section recipe-bottom-section">
|
<div class="info-section recipe-bottom-section">
|
||||||
<div class="recipe-section-header">
|
<div class="recipe-section-header">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
MODEL_CARD_MODULE,
|
MODEL_CARD_MODULE,
|
||||||
@@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => {
|
|||||||
return createModelCard(model, 'loras');
|
return createModelCard(model, 'loras');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchDrop(card, files) {
|
function dispatchDrop(card, files, types = []) {
|
||||||
const event = new Event('drop', { bubbles: true, cancelable: true });
|
const event = new Event('drop', { bubbles: true, cancelable: true });
|
||||||
Object.defineProperty(event, 'dataTransfer', { value: { files } });
|
Object.defineProperty(event, 'dataTransfer', { value: { files, types } });
|
||||||
card.dispatchEvent(event);
|
card.dispatchEvent(event);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => {
|
|||||||
expect(event.defaultPrevented).toBe(true);
|
expect(event.defaultPrevented).toBe(true);
|
||||||
expect(card.classList.contains('drag-over')).toBe(false);
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores drops tagged as internal card drags (move-to-folder)', () => {
|
||||||
|
const card = createCard();
|
||||||
|
const file = new File(['data'], 'preview.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
const event = dispatchDrop(card, [file], [MODEL_CARD_DRAG_MIME_TYPE]);
|
||||||
|
|
||||||
|
expect(uploadPreviewMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not highlight or intercept internal card drags during dragover', () => {
|
||||||
|
const card = createCard();
|
||||||
|
|
||||||
|
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(dragOverEvent, 'dataTransfer', {
|
||||||
|
value: { types: [MODEL_CARD_DRAG_MIME_TYPE] },
|
||||||
|
});
|
||||||
|
card.dispatchEvent(dragOverEvent);
|
||||||
|
|
||||||
|
expect(dragOverEvent.defaultPrevented).toBe(false);
|
||||||
|
expect(card.classList.contains('drag-over')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the card draggable (move-to-folder) but the preview image non-draggable', () => {
|
||||||
|
const card = createCard();
|
||||||
|
|
||||||
|
// The card itself must stay draggable for sidebar move-to-folder drags.
|
||||||
|
expect(card.draggable).toBe(true);
|
||||||
|
// The preview image must not start a native image drag: the browser would
|
||||||
|
// synthesize a File payload from it, which the drop handler would mistake
|
||||||
|
// for an external preview replacement.
|
||||||
|
const img = card.querySelector('.card-preview img');
|
||||||
|
expect(img.getAttribute('draggable')).toBe('false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(SHOWCASE_MODULE, () => ({
|
vi.mock(SHOWCASE_MODULE, () => ({
|
||||||
toggleShowcase: vi.fn(),
|
|
||||||
setupShowcaseScroll: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
scrollToTop: vi.fn(),
|
||||||
loadExampleImages: vi.fn(),
|
loadExampleImages: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock(SHOWCASE_MODULE, () => ({
|
vi.mock(SHOWCASE_MODULE, () => ({
|
||||||
toggleShowcase: vi.fn(),
|
|
||||||
setupShowcaseScroll: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
scrollToTop: vi.fn(),
|
||||||
loadExampleImages: vi.fn(),
|
loadExampleImages: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
||||||
|
|
||||||
|
const { SHOWCASE_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
|
||||||
|
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||||
|
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
|
||||||
|
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MEDIA_UTILS_MODULE, () => ({
|
||||||
|
initLazyLoading: vi.fn(),
|
||||||
|
initNsfwBlurHandlers: vi.fn(),
|
||||||
|
initMetadataPanelHandlers: vi.fn(),
|
||||||
|
initMediaControlHandlers: vi.fn(),
|
||||||
|
positionAllMediaControls: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MEDIA_VIEWER_MODULE, () => ({
|
||||||
|
openMediaViewer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PREVIEW_URL = '/loras_static/preview/abc.png';
|
||||||
|
|
||||||
|
const IMAGES = [
|
||||||
|
{ url: 'https://image.civitai.com/abc/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/abc/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/abc/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Showcase gallery', () => {
|
||||||
|
let state;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
state = stateModule.state;
|
||||||
|
state.settings.show_only_sfw = false;
|
||||||
|
state.settings.blur_mature_content = false;
|
||||||
|
state.global.settings.example_images_path = '/tmp/examples';
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts collapsed: slim indicator bar only, no remote examples rendered', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.showcase-gallery')).toBeTruthy();
|
||||||
|
expect(host.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
// Collapsed bar carries the count and the local preview thumbnail
|
||||||
|
expect(host.querySelector('#galleryShowBtn')?.textContent).toContain('3');
|
||||||
|
expect(host.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||||
|
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||||
|
// No thumbnails / media wrappers → no remote fetches until expanded
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
expect(host.querySelector('.media-wrapper')).toBeNull();
|
||||||
|
// Import zone exists but stays collapsed
|
||||||
|
const zone = host.querySelector('.gallery-import-zone');
|
||||||
|
expect(zone?.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expanded render shows toolbar, main viewer, thumbnails and nav controls', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||||
|
expect(host.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||||
|
expect(host.querySelector('.main-media-container .media-wrapper')).toBeTruthy();
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||||
|
expect(host.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||||
|
expect(host.querySelector('#galleryPrevBtn')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#galleryNextBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show/hide button toggles between indicator bar and gallery', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
document.querySelector('#galleryShowBtn').click();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(3);
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeNull();
|
||||||
|
|
||||||
|
// Collapse back to the indicator bar
|
||||||
|
document.querySelector('#galleryShowBtn').click();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
expect(document.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the import zone when the example images path is not configured', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.global.settings.example_images_path = '';
|
||||||
|
|
||||||
|
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
|
||||||
|
expect(host.querySelector('.gallery-import-zone')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters NSFW examples and reports the hidden count', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.settings.show_only_sfw = true;
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
IMAGES[0],
|
||||||
|
{ url: 'https://image.civitai.com/abc/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
|
||||||
|
];
|
||||||
|
const html = renderShowcaseContent(images, [], '', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(1);
|
||||||
|
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
|
||||||
|
// Only one example left → no prev/next controls
|
||||||
|
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the import interface when there are no examples', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const html = renderShowcaseContent([], [], PREVIEW_URL);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.example-import-area.empty')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#selectExampleFilesBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the setup guidance when the path is missing and there are no examples', async () => {
|
||||||
|
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
state.global.settings.example_images_path = '';
|
||||||
|
|
||||||
|
const html = renderShowcaseContent([], []);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.innerHTML = html;
|
||||||
|
|
||||||
|
expect(host.querySelector('.import-container--needs-setup')).toBeTruthy();
|
||||||
|
expect(host.querySelector('#openExampleSettingsBtn')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the main display, position and active thumbnail when expanded', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
updateMainDisplay(2);
|
||||||
|
|
||||||
|
const activeThumb = document.querySelector('.gallery-thumb.active');
|
||||||
|
expect(activeThumb?.dataset.index).toBe('2');
|
||||||
|
expect(document.querySelector('#galleryPosition')?.textContent).toBe('3 / 3');
|
||||||
|
const mainWrapper = document.querySelector('#mainMediaContainer .media-wrapper');
|
||||||
|
expect(mainWrapper).toBeTruthy();
|
||||||
|
// The third example is a video
|
||||||
|
expect(mainWrapper.querySelector('video')).toBeTruthy();
|
||||||
|
|
||||||
|
// Wraps around past the end
|
||||||
|
updateMainDisplay(3);
|
||||||
|
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
|
||||||
|
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits the main viewer to the active media aspect ratio', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
// First image is portrait 512x768 → aspect 0.667
|
||||||
|
const container = document.getElementById('mainMediaContainer');
|
||||||
|
expect(container.style.getPropertyValue('--media-aspect')).toBe(String(512 / 768));
|
||||||
|
|
||||||
|
// Second image is landscape 768x512 → aspect 1.5
|
||||||
|
updateMainDisplay(1);
|
||||||
|
expect(container.style.getPropertyValue('--media-aspect')).toBe('1.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores main-display updates while collapsed', async () => {
|
||||||
|
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
|
||||||
|
|
||||||
|
updateMainDisplay(1);
|
||||||
|
|
||||||
|
// Still collapsed: indicator bar untouched, no gallery rendered
|
||||||
|
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
|
||||||
|
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
|
||||||
|
|
||||||
const { SHOWCASE_MODULE } = vi.hoisted(() => ({
|
|
||||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Showcase listener metrics', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div id="modelModal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="showcase-section">
|
|
||||||
<div class="carousel collapsed">
|
|
||||||
<div class="scroll-indicator"></div>
|
|
||||||
</div>
|
|
||||||
<button class="back-to-top"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks wheel/mutation/back-to-top listeners and resets after cleanup', async () => {
|
|
||||||
const {
|
|
||||||
setupShowcaseScroll,
|
|
||||||
resetShowcaseListenerMetrics,
|
|
||||||
showcaseListenerMetrics,
|
|
||||||
} = await import(SHOWCASE_MODULE);
|
|
||||||
|
|
||||||
resetShowcaseListenerMetrics();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
|
|
||||||
const cleanup = setupShowcaseScroll('modelModal');
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(1);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(1);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(1);
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('remains stable after repeated setup/cleanup cycles', async () => {
|
|
||||||
const {
|
|
||||||
setupShowcaseScroll,
|
|
||||||
resetShowcaseListenerMetrics,
|
|
||||||
showcaseListenerMetrics,
|
|
||||||
} = await import(SHOWCASE_MODULE);
|
|
||||||
|
|
||||||
resetShowcaseListenerMetrics();
|
|
||||||
|
|
||||||
const cleanupA = setupShowcaseScroll('modelModal');
|
|
||||||
cleanupA();
|
|
||||||
|
|
||||||
const cleanupB = setupShowcaseScroll('modelModal');
|
|
||||||
cleanupB();
|
|
||||||
|
|
||||||
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
|
|
||||||
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -83,3 +83,64 @@ def test_checkpoint_names_empty_when_scanner_fails(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
||||||
assert CheckpointLoaderLM._get_checkpoint_names() == []
|
assert CheckpointLoaderLM._get_checkpoint_names() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_available_base_models(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
sd15 = tmp_path / "sd15.safetensors"
|
||||||
|
sd15.write_bytes(b"x")
|
||||||
|
flux = tmp_path / "flux.safetensors"
|
||||||
|
flux.write_bytes(b"x")
|
||||||
|
missing = tmp_path / "missing.safetensors" # referenced but never created
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(sd15), "base_model": "SD1.5"},
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "Flux.1 D"},
|
||||||
|
# Deleted files must drop out; wrong sub_type must be excluded.
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
|
||||||
|
{"sub_type": "diffusion_model", "file_path": str(flux), "base_model": "Flux.1 D"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
assert CheckpointLoaderLM._get_available_base_models() == [
|
||||||
|
"Any",
|
||||||
|
"Flux.1 D",
|
||||||
|
"SD1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unet_available_base_models(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
flux = tmp_path / "flux.safetensors"
|
||||||
|
flux.write_bytes(b"x")
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{
|
||||||
|
"sub_type": "diffusion_model",
|
||||||
|
"file_path": str(flux),
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
},
|
||||||
|
# Checkpoint entries must stay excluded by the sub_type filter.
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "SD1.5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
assert UNETLoaderLM._get_available_base_models() == ["Any", "Flux.1 D"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_base_models_empty_when_scanner_fails(tmp_path, monkeypatch):
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
def _boom():
|
||||||
|
raise RuntimeError("scanner not available")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
|
||||||
|
assert CheckpointLoaderLM._get_available_base_models() == ["Any"]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests for the loader-pool endpoint backing the Random Checkpoint/Unet
|
||||||
|
Loader nodes' front-end base_model filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.checkpoint_routes import CheckpointRoutes
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCache:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScanner:
|
||||||
|
def __init__(self, raw_data, model_roots):
|
||||||
|
self._raw_data = raw_data
|
||||||
|
self._model_roots = model_roots
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh=False):
|
||||||
|
return _FakeCache(self._raw_data)
|
||||||
|
|
||||||
|
def get_model_roots(self):
|
||||||
|
return self._model_roots
|
||||||
|
|
||||||
|
|
||||||
|
class DummyRequest:
|
||||||
|
def __init__(self, query=None):
|
||||||
|
self.query = query or {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def routes(tmp_path, monkeypatch):
|
||||||
|
existing = tmp_path / "flux.safetensors"
|
||||||
|
existing.write_bytes(b"x")
|
||||||
|
missing = tmp_path / "missing.safetensors" # referenced but never created
|
||||||
|
|
||||||
|
raw_data = [
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(existing), "base_model": "Flux.1 D"},
|
||||||
|
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
|
||||||
|
{
|
||||||
|
"sub_type": "diffusion_model",
|
||||||
|
"file_path": str(existing),
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _fake_scanner():
|
||||||
|
return _FakeScanner(raw_data, [str(tmp_path)])
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
|
||||||
|
return CheckpointRoutes()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_checkpoint_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "checkpoint"}))
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_diffusion_model_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(
|
||||||
|
DummyRequest(query={"sub_type": "diffusion_model"})
|
||||||
|
)
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_default_subtype_is_checkpoint(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest())
|
||||||
|
assert response.status == 200
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload == {
|
||||||
|
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loader_pool_invalid_subtype(routes):
|
||||||
|
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "lora"}))
|
||||||
|
assert response.status == 400
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
const NODE_CONFIGS = {
|
||||||
|
"Checkpoint Loader (LoraManager)": {
|
||||||
|
modelWidget: "ckpt_name",
|
||||||
|
subType: "checkpoint",
|
||||||
|
},
|
||||||
|
"Unet Loader (LoraManager)": {
|
||||||
|
modelWidget: "unet_name",
|
||||||
|
subType: "diffusion_model",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const poolCache = new Map();
|
||||||
|
|
||||||
|
async function fetchPool(subType) {
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/api/lm/checkpoints/loader-pool?sub_type=${encodeURIComponent(subType)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data.items) ? data.items : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("LoRA Manager: failed to fetch loader pool", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPoolCache() {
|
||||||
|
const subTypes = new Set(Object.values(NODE_CONFIGS).map((c) => c.subType));
|
||||||
|
await Promise.all(
|
||||||
|
[...subTypes].map(async (subType) => {
|
||||||
|
poolCache.set(subType, await fetchPool(subType));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBaseModelFilter(node, config) {
|
||||||
|
const modelWidget = node.widgets?.find(
|
||||||
|
(widget) => widget.name === config.modelWidget
|
||||||
|
);
|
||||||
|
const baseModelWidget = node.widgets?.find(
|
||||||
|
(widget) => widget.name === "base_model"
|
||||||
|
);
|
||||||
|
if (!modelWidget || !baseModelWidget) return;
|
||||||
|
|
||||||
|
const wired = node.inputs?.some(
|
||||||
|
(input) =>
|
||||||
|
input.widget?.name === config.modelWidget && input.link != null
|
||||||
|
);
|
||||||
|
if (wired) return;
|
||||||
|
|
||||||
|
const pool = poolCache.get(config.subType) ?? [];
|
||||||
|
const filter = baseModelWidget.value;
|
||||||
|
const filtered =
|
||||||
|
filter === "Any"
|
||||||
|
? pool
|
||||||
|
: pool.filter((model) => model.base_model === filter);
|
||||||
|
const names = filtered.map((model) => model.name);
|
||||||
|
|
||||||
|
modelWidget.options.values = names;
|
||||||
|
if (!names.includes(modelWidget.value)) {
|
||||||
|
modelWidget.value = names[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToAllNodes() {
|
||||||
|
app.graph?.nodes?.forEach((node) => {
|
||||||
|
const config = NODE_CONFIGS[node.comfyClass];
|
||||||
|
if (config) applyBaseModelFilter(node, config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGraphConfigureHook(graph) {
|
||||||
|
if (!graph || graph.__loraManagerConfigureHooked) return;
|
||||||
|
graph.__loraManagerConfigureHooked = true;
|
||||||
|
|
||||||
|
const originalConfigure = graph.onConfigure;
|
||||||
|
graph.onConfigure = function (data) {
|
||||||
|
const result = originalConfigure?.call(this, data);
|
||||||
|
// Workflow reload restores widget values after onNodeCreated fires, so the
|
||||||
|
// per-node hook runs too early; re-apply the filter once the whole graph
|
||||||
|
// has been configured.
|
||||||
|
setTimeout(() => applyToAllNodes(), 0);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.RandomLoaderControl",
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
await refreshPoolCache();
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
|
const config = NODE_CONFIGS[nodeType.comfyClass];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
|
|
||||||
|
const baseModelWidget = this.widgets?.find(
|
||||||
|
(widget) => widget.name === "base_model"
|
||||||
|
);
|
||||||
|
if (baseModelWidget) {
|
||||||
|
const originalCallback = baseModelWidget.callback;
|
||||||
|
baseModelWidget.callback = (value, canvas, node, pos, event) => {
|
||||||
|
applyBaseModelFilter(node ?? this, config);
|
||||||
|
return originalCallback?.call(this, value, canvas, node, pos, event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBaseModelFilter(this, config);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// onNodeCreated fires inside LGraph.createNode, before the node is added to
|
||||||
|
// a graph (this.graph is null there), so the graph-level configure hook
|
||||||
|
// must be installed from onAdded, where the graph reference is available.
|
||||||
|
const onAdded = nodeType.prototype.onAdded;
|
||||||
|
nodeType.prototype.onAdded = function () {
|
||||||
|
const result = onAdded?.apply(this, arguments);
|
||||||
|
ensureGraphConfigureHook(this.graph);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshComboInNodes() {
|
||||||
|
await refreshPoolCache();
|
||||||
|
applyToAllNodes();
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user