diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..867182b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.DS_Store +Thumbs.db +.env +venv/ +*.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..af58948 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Neurad Visual LoRA Loader + +**A powerful, user-friendly ComfyUI extension for managing, organizing, and loading LoRAs with metadata intelligence.** + +Neurad Visual LoRA Loader replaces the standard text-based LoRA selection with a rich, visual interface. It features intelligent caching, tab-based organization, drag-and-drop workflow, and seamless integration with CivitAI to fetch metadata and trigger words automatically. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![ComfyUI](https://img.shields.io/badge/ComfyUI-Compatible-green.svg) +![Python](https://img.shields.io/badge/Python-3.10+-blue.svg) + +--- + +## 🌟 Key Features + +### 🎨 Visual Catalog & Grid +* **Rich Previews:** Browse your LoRAs in a responsive grid with cover images. +* **Smart Caching:** Images are cached locally and in-memory for instant loading, even with large libraries. +* **Dynamic Filtering:** Filter by name, file path, folder, or state (Active/Inactive, Has Metadata/No Metadata). +* **NSFW Auto-Filter:** Intelligent content filtering that hides NSFW-tagged files and folders unless explicitly requested. + +### 🗂️ Tab-Based Organization +* **Custom Tabs:** Create unlimited custom tabs to organize LoRAs by project, style, or character. +* **Drag & Drop:** Easily move LoRAs between tabs or reorder them within a tab using intuitive drag-and-drop. +* **Live Counters:** Each tab displays real-time counts of active vs. inactive LoRAs. + +### 🧠 Metadata Intelligence +* **CivitAI Integration:** One-click fetching of metadata (Trigger Words, Recommended Strength, Images) directly from CivitAI. +* **Local Editing:** Override metadata manually. Edit names, set custom cover images, define trigger words, and set strength ranges. +* **Dual Persistence:** Data is saved in both browser LocalStorage (for speed) and server-side disk storage (for backup and persistence across sessions). + +### ⚡ Workflow Integration +* **Visual Strength Control:** Adjust LoRA strength directly from the card. +* **Active List Preview:** The node background displays a live list of currently active LoRAs and their strengths. +* **Hidden Data Widget:** Seamlessly passes JSON data to the ComfyUI backend without cluttering the node interface. + +--- + +## 📸 Screenshots + +*(Add your screenshots here in the `images/` folder and reference them below)* + +| Visual Grid & Tabs | Metadata Editor | +| :---: | :---: | +| ![Grid View](./images/screenshot_grid.png) | ![Editor Modal](./images/screenshot_editor.png) | +| *Browse and organize your library visually.* | *Edit trigger words, strengths, and cover images.* | + +--- + +## 🛠️ Installation + +### Prerequisites +* **ComfyUI** installed and running. +* **Python 3.10+** + +### Method 1: ComfyUI Manager (Recommended) +1. Open **ComfyUI Manager**. +2. Click **"Install Custom Nodes"**. +3. Search for `Visual LoRA Loader`. +4. Click **Install** and restart ComfyUI. + +### Method 2: Manual Installation +1. Navigate to your ComfyUI `custom_nodes` directory: + ```bash + cd ComfyUI/custom_nodes + ``` +2. Clone this repository: + ```bash + git clone https://github.com/YOUR_USERNAME/Visual-LoRA-Loader.git + ``` +3. **Restart ComfyUI** completely. + +--- + +## 🚀 Usage Guide + +### 1. Opening the Library +* Add the **Visual LoRA Loader** node to your workflow. +* Click the **"Open Library"** button on the node. +* A large floating modal will appear with your full LoRA catalog. + +### 2. Managing LoRAs +* **Activate/Deactivate:** Click anywhere on a card to toggle it On/Off. +* **Adjust Strength:** Use the `◄` / `►` buttons or click the number to type a specific strength value. +* **Get Info:** If a card shows a "📥 Get Info" placeholder, click it to fetch metadata from CivitAI. +* **Edit Details:** Click the **ℹ️** icon on a card to view details. Inside the modal, click **✏️ Edit** to manually change names, trigger words, cover images, or recommended strength. +* **Image Details:** Inside the modal, click an image to zoom in. Images displaying the **📝** icon include available generation metadata. +* **Trigger Words:** Inside the modal, you can select multiple trigger words simultaneously; they are automatically copied to the clipboard as a comma-separated list. + +### 3. Organizing with Tabs +* **Create Tab:** Click the **+** button in the tab bar. +* **Rename:** Double-click a tab name. +* **Move LoRAs:** Drag a card from the "Library" tab (or any other tab) and drop it onto a custom tab to copy it there. +* **Reorder:** Drag cards within a tab to reorder them. A blue line indicates where the card will be dropped. + +### 4. Filtering Results +* **Search Field:** Enter any string to include or exclude it. Words are tested individually against LoRA names, filenames, and paths. +* **Filter Buttons:** Select **On/Off** and/or **Has Meta/No Meta** to further refine your search. +* **Consistent Results:** Displayed LoRAs will always satisfy all specified criteria simultaneously. +* **Automatic NSFW Filtering:** "nsfw" is a pre-activated negative keyword. It automatically hides any LoRA with "nsfw" in its name, filename, or path. To display these LoRAs, explicitly include "nsfw" in the positive search field. + +### 5. Using in Workflow +* Once you have selected your LoRAs in the visual interface, close the modal. +* The node will automatically update its internal hidden widget with the selected LoRAs and strengths. +* Connect the node to your **Checkpoint Loader** or **LoRA Loader** chain as required by your specific implementation logic. + +--- + +## 💡 Library vs. Node Context + +Understanding the separation between **Global Data** and **Node Configuration** is key to mastering this extension: + +| Feature | Scope | Behavior | +| :--- | :--- | :--- | +| **LoRA Metadata** | **Global (User)** | Names, trigger words, cover images, and strengths fetched from CivitAI or edited manually are saved **globally**. If you edit a LoRA's name in one workflow, it updates everywhere. | +| **Tabs & Organization** | **Per-Node** | Custom tabs, the order of LoRAs within tabs, and which LoRAs are currently **Active/On** are saved **specific to each node instance**. | +| **Workflow Portability** | **Portable** | When you copy a **Visual LoRA Loader** node from one workflow to another, it brings its own unique tab structure and active selection with it, while still accessing your single, global library of metadata. | + +> **Pro Tip:** Create a "Master Library" node with your favorite general-purpose tabs for daily use. Then, create separate nodes for specific projects (e.g., "Portrait Workflow," "Anime Workflow") with their own tailored tab setups. They all share the same underlying metadata intelligence but maintain independent organizations. + +--- + +## ⚙️ Technical Details + +### Data Persistence +The extension uses a robust dual-layer storage system: +1. **Browser LocalStorage:** Used for immediate read/write access during your session. +2. **Server Disk Backup:** A debounced backup system writes your changes to the server's disk. If you clear your browser cache, the extension will automatically offer to restore your data from the server backup upon the next launch. + +### Caching System +* **Image Cache:** Keeps up to 800 images in memory to prevent flickering and reduce network requests. +* **Metadata Cache:** Stores fetched CivitAI data locally to avoid hitting API rate limits. + +### Clearing Cache +A hidden **"⚠️ Clear Cache"** button is available in the top-left of the modal. +* **How to access:** Hover over the button for **1 second**. It will turn red and become clickable. +* **Action:** This wipes both local browser storage and server-side backup files. Use with caution. + +--- + +## 🤝 Contributing + +Contributions are welcome! Whether it's fixing a bug, improving the UI, or adding new features: + +1. Fork the repository. +2. Create your feature branch (`git checkout -b feature/AmazingFeature`). +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`). +4. Push to the branch (`git push origin feature/AmazingFeature`). +5. Open a Pull Request. + +--- + +## 📄 License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +--- + +## 🙏 Acknowledgments + +* Built for the **ComfyUI** community. +* Metadata powered by **CivitAI**. +* Inspired by the need for better asset management in AI art workflows. + +--- + +**Enjoy organizing your LoRAs!** 🎨🚀 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5ba646c --- /dev/null +++ b/__init__.py @@ -0,0 +1,15 @@ +from .visual_lora_loader import ( + NODE_CLASS_MAPPINGS as _VISUAL_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _VISUAL_NODE_DISPLAY_NAME_MAPPINGS, +) + + +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(_VISUAL_NODE_CLASS_MAPPINGS) + +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(_VISUAL_NODE_DISPLAY_NAME_MAPPINGS) + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/images/screenshot_editor.png.png b/images/screenshot_editor.png.png new file mode 100644 index 0000000..3a38ff0 Binary files /dev/null and b/images/screenshot_editor.png.png differ diff --git a/images/screenshot_grid.png.png b/images/screenshot_grid.png.png new file mode 100644 index 0000000..a4b027a Binary files /dev/null and b/images/screenshot_grid.png.png differ diff --git a/visual_lora_loader.py b/visual_lora_loader.py new file mode 100644 index 0000000..d5677d3 --- /dev/null +++ b/visual_lora_loader.py @@ -0,0 +1,397 @@ +import folder_paths +import json +import os +import hashlib +import requests +from datetime import datetime +from nodes import LoraLoader +from server import PromptServer +from aiohttp import web +import logging +import threading + +CIVITAI_API_BASE = "https://civitai.com/api/v1" +# On stocke le cache dans le dossier utilisateur de ComfyUI (comme rgthree) +# Ex: ComfyUI/user/nomad/neurad_cache/ +CACHE_DIR = os.path.join(folder_paths.user_directory, "neurad_cache") +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + +class VisualLoraLoaderNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "lora_data": ("STRING", {"default": "[]", "multiline": False, "hidden": True}), + } + } + RETURN_TYPES = ("MODEL", "CLIP", "STRING") + RETURN_NAMES = ("MODEL", "CLIP", "active_loras_info") + FUNCTION = "load_lora" + CATEGORY = "loaders" + + def load_lora(self, model, clip, lora_data): + try: active_loras = json.loads(lora_data) + except: return (model, clip, "Error") + current_model, current_clip = model, clip + lora_loader_instance = LoraLoader() + applied_info = [] + for lora in active_loras: + lora_name = lora.get("name") + strength_model = float(lora.get("strength_model", 1.0)) + strength_clip = float(lora.get("strength_clip", strength_model)) + subfolder = lora.get("subfolder", "") + full_name = f"{subfolder.replace('\\', '/')}/{lora_name.replace('\\', '/')}".replace("//", "/").strip("/") + path = folder_paths.get_full_path("loras", full_name) or folder_paths.get_full_path("loras", lora_name) + if path: + try: + current_model, current_clip = lora_loader_instance.load_lora(current_model, current_clip, full_name, strength_model, strength_clip) + applied_info.append(f"{full_name} ({strength_model})") + except Exception as e: logging.error(f"[Neurad] Load error: {e}") + return (current_model, current_clip, ", ".join(applied_info)) + +@PromptServer.instance.routes.post("/neurad/reset-cache") +async def reset_cache(request): + """ + PURGE TOTALE : Supprime le fichier de backup ET tous les fichiers de cache par hash. + """ + try: + # 1. Supprimer le backup LocalStorage + backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json") + if os.path.exists(backup_path): + os.remove(backup_path) + logging.info("[Neurad] Backup file deleted.") + + # 2. Supprimer TOUS les fichiers de cache .json (sauf le backup qu'on vient de traiter) + # On liste tous les fichiers dans le dossier de cache + for filename in os.listdir(CACHE_DIR): + if filename.endswith(".json") and filename != "localStorage_backup.json": + file_path = os.path.join(CACHE_DIR, filename) + try: + os.remove(file_path) + except Exception as e: + logging.warning(f"[Neurad] Could not delete cache file {filename}: {e}") + + # 3. CRÉER LE DRAPEAU DE PURGE (Fichier vide) + flag_path = os.path.join(CACHE_DIR, "purge_flag.lock") + with open(flag_path, 'w') as f: + f.write("purged") # Ou laisser vide + + logging.info("[Neurad] Full cache purge completed.") + return web.json_response({"success": True, "message": "Cache purged"}) + except Exception as e: + logging.error(f"[Neurad] Critical purge error: {e}") + return web.json_response({"error": str(e)}, status=500) + +def get_sha256_hash(file_path): + """Calcule le hash SHA256 du fichier (comme rgthree).""" + if not os.path.exists(file_path): return None + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + +def get_cache_file_path(file_hash): + return os.path.join(CACHE_DIR, f"{file_hash}.json") + +def load_from_cache(file_hash): + cache_path = get_cache_file_path(file_hash) + if os.path.exists(cache_path): + try: + with open(cache_path, 'r', encoding='utf-8') as f: + data = json.load(f) + # Vérifier si le cache est trop vieux (optionnel, ici 24h) + # if datetime.now().timestamp() - data.get('timestamp', 0) < 86400: + return data.get('response') + except: pass + return None + +def has_cache_for_file(file_path): + """Vérifie simplement si un fichier de cache existe pour ce fichier .safetensors.""" + file_hash = get_sha256_hash(file_path) + if not file_hash: + return False + cache_path = get_cache_file_path(file_hash) + return os.path.exists(cache_path) + +def save_to_cache(file_hash, data): + cache_path = get_cache_file_path(file_hash) + try: + with open(cache_path, 'w', encoding='utf-8') as f: + json.dump({'timestamp': datetime.now().timestamp(), 'response': data}, f, indent=2) + except Exception as e: + logging.warning(f"[Neurad] Cache write error (non-critical): {e}") + +def fetch_civitai_by_hash(file_hash): + """Appelle l'API Civitai par HASH (la méthode magique de rgthree).""" + url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{file_hash}" + try: + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + # Vérifier que c'est bien un modèle (parfois le hash ne correspond à rien) + if data and 'model' in data: + return data + elif response.status_code == 404: + logging.info(f"[Neurad] No model found for hash {file_hash}") + else: + logging.warning(f"[Neurad] API Error {response.status_code} for hash {file_hash}") + except Exception as e: + logging.warning(f"[Neurad] Request failed: {e}") + return None + +def parse_civitai_response(data): + """Formate la réponse Civitai pour notre frontend.""" + if not data: + return None + + # 1. Extraction ROBUSTE de l'ID du MODÈLE (pas la version) + model_id = None + + # Priorité 1: Le champ 'modelId' explicite (souvent à la racine pour cette API) + if 'modelId' in data: + model_id = data.get('modelId') + + # Priorité 2: L'objet 'model' imbriqué + if not model_id and 'model' in data and isinstance(data['model'], dict): + model_id = data['model'].get('id') + + # Priorité 3: Fallback sur 'id' si on pense que c'est le modèle (rare pour by-hash) + # Mais attention, si on n'a que l'ID de version, on doit le gérer différemment pour l'URL + version_id = data.get('id') + + if not model_id and version_id: + # Si on n'a trouvé que l'ID de version, on essaie de voir si 'model' existe quand même + # Sinon, on devra construire l'URL avec l'ID de version en paramètre + pass + + # 2. Construction de l'URL CORRECTE + civitai_url = "https://civitai.com/models" + + if model_id: + civitai_url += f"/{model_id}" + # Si on a aussi un ID de version différent, on l'ajoute en paramètre pour tomber sur la bonne version + if version_id and version_id != model_id: + civitai_url += f"?modelVersionId={version_id}" + elif version_id: + # Cas rare : on n'a que l'ID de version, on tente l'URL directe (ça redirige souvent) + # Mais c'est mieux d'avoir le modelId. Si on est ici, c'est qu'on n'a pas trouvé le modelId. + # On laisse l'URL de base ou on tente avec l'ID de version (moins fiable) + civitai_url += f"/{version_id}" # Risqué, mais mieux que None + + # 3. Nom + model_name = "" + if 'model' in data and isinstance(data['model'], dict): + model_name = data['model'].get('name', '') + + version_name = data.get('name', '') + full_name = model_name + if version_name and model_name and version_name != model_name: + full_name += f" - {version_name}" + elif not model_name: + full_name = version_name + + # 4. Images + images = [] + # 4. Images (Toutes les images, sans limite) + images = [] + if 'images' in data: + for img in data['images']: + meta_data = img.get('meta', {}) + if isinstance(meta_data, str): + try: meta_data = json.loads(meta_data) + except: meta_data = {} + + images.append({ + "url": img.get('url'), + "nsfwLevel": img.get('nsfwLevel', 0), + "width": img.get('width'), + "height": img.get('height'), + "positive": meta_data.get('prompt', '') if meta_data else "" + }) + + return { + "source": "civitai_hash", + "model_id": model_id if model_id else version_id, + "name": full_name, + "trainedWords": (data.get('trainedWords', []) or []) + (data.get('triggerWords', []) or []), + "images": images, + "strengthMin": None, + "strengthMax": None, + "civitaiUrl": civitai_url + } + +@PromptServer.instance.routes.get("/neurad/get-loras") +async def get_loras(request): + """ + LISTE ULTRA-RAPIDE. + Ne calcule AUCUN hash, ne lit AUCUN cache. + Renvoie has_meta: False pour tout le monde. + C'est le JS (LocalStorage) qui gère l'affichage des icônes déjà connues. + """ + lora_folders = folder_paths.get_folder_paths("loras") + all_loras = [] + + for root_folder in lora_folders: + if not os.path.exists(root_folder): continue + for root, dirs, files in os.walk(root_folder): + for filename in files: + if filename.endswith(".safetensors"): + try: + rel_path = os.path.relpath(os.path.join(root, filename), root_folder) + except: + rel_path = filename + + all_loras.append({ + "name": filename, + "display_name": os.path.splitext(filename)[0], + "relative_path": rel_path, + "subfolder": os.path.dirname(rel_path), + "has_meta": False, # Toujours faux ici pour la vitesse + "meta": {} # Vide + }) + + all_loras.sort(key=lambda x: (x["subfolder"], x["display_name"].lower())) + return web.json_response(all_loras) + +@PromptServer.instance.routes.post("/neurad/fetch-single-info") +async def fetch_single_info(request): + """ + MÉTHODE RGTHREE : Calcule le HASH -> Appelle API By-Hash -> Cache dans user/ + """ + try: + data = await request.json() + rel_path = data.get("path") + if not rel_path: return web.json_response({"error": "No path"}, status=400) + + full_path = None + for root in folder_paths.get_folder_paths("loras"): + p = os.path.join(root, rel_path) + if os.path.exists(p): full_path = p; break + + if not full_path: return web.json_response({"error": "File not found"}, status=404) + + # 1. Calcul du Hash (Peut prendre 1-2s pour un gros fichier) + file_hash = get_sha256_hash(full_path) + if not file_hash: + return web.json_response({"error": "Could not calculate hash"}, status=500) + + # 2. Vérifier le cache local + cached_data = load_from_cache(file_hash) + if cached_data: + parsed = parse_civitai_response(cached_data) + if parsed: return web.json_response({"success": True, "meta": parsed}) + + # 3. Fetch API par Hash + raw_data = fetch_civitai_by_hash(file_hash) + if not raw_data: + return web.json_response({"success": False, "error": "No model found on Civitai for this file hash"}, status=404) + + # 4. Parser et Sauvegarder + parsed = parse_civitai_response(raw_data) + if parsed: + save_to_cache(file_hash, raw_data) + return web.json_response({"success": True, "meta": parsed}) + + return web.json_response({"success": False, "error": "Data parsing failed"}, status=500) + + except Exception as e: + logging.error(f"[Neurad] Critical Error: {e}") + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/neurad/save-localstorage-backup") +async def save_backup(request): + """Reçoit les données du JS et les sauvegarde sur le disque dur.""" + try: + data = await request.json() + backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json") + with open(backup_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + logging.info("[Neurad] LocalStorage backup saved to disk.") + return web.json_response({"success": True}) + except Exception as e: + logging.error(f"[Neurad] Backup save error: {e}") + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/neurad/update-lora-info") +async def update_lora_info(request): + """ + Reçoit les données modifiées par l'utilisateur et met à jour le fichier de cache JSON correspondant. + """ + try: + data = await request.json() + lora_path = data.get("path") # Le chemin relatif unique (ex: "subfolder/model.safetensors") + updated_meta = data.get("meta") # Les nouvelles données (nom, triggers, etc.) + + if not lora_path or not updated_meta: + return web.json_response({"error": "Missing path or meta data"}, status=400) + + backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json") + backup_data = {} + + if os.path.exists(backup_path): + try: + with open(backup_path, 'r', encoding='utf-8') as f: + backup_data = json.load(f) + except: + backup_data = {} + + # Mise à jour des données pour cette LoRA + backup_data[lora_path] = updated_meta + + # Ajout d'un flag pour indiquer que c'est édité manuellement (optionnel mais utile) + backup_data[lora_path]['edited_by_user'] = True + + # Sauvegarde sur le disque + with open(backup_path, 'w', encoding='utf-8') as f: + json.dump(backup_data, f, indent=2, ensure_ascii=False) + + logging.info(f"[Neurad] Updated manual data for: {lora_path}") + return web.json_response({"success": True}) + + except Exception as e: + logging.error(f"[Neurad] Update error: {e}") + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.get("/neurad/load-localstorage-backup") +async def load_backup(request): + """Renvoie les données sauvegardées sur le disque pour restaurer le JS.""" + try: + flag_path = os.path.join(CACHE_DIR, "purge_flag.lock") + + # SI LE DRAPEAU EXISTE : On bloque la restauration et on nettoie le drapeau + if os.path.exists(flag_path): + try: + os.remove(flag_path) + logging.info("[Neurad] Purge flag detected. Blocking restoration to ensure clean state.") + except: + pass + # On renvoie des données vides, peu importe si le backup existe + return web.json_response({"success": False, "data": {}, "blocked_by_purge": True}) + + backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json") + if os.path.exists(backup_path): + # Vérification de sécurité : si le fichier est vide ou corrompu, on ne restaure rien + try: + with open(backup_path, 'r', encoding='utf-8') as f: + data = json.load(f) + # On ne restaure que si c'est un dictionnaire non vide + if isinstance(data, dict) and len(data) > 0: + return web.json_response({"success": True, "data": data}) + else: + # Fichier vide ou invalide, on considère qu'il n'y a rien à restaurer + return web.json_response({"success": False, "data": {}}) + except json.JSONDecodeError: + logging.warning("[Neurad] Backup file corrupted, ignoring.") + return web.json_response({"success": False, "data": {}}) + else: + return web.json_response({"success": False, "data": {}}) + except Exception as e: + logging.error(f"[Neurad] Backup load error: {e}") + return web.json_response({"error": str(e)}, status=500) + +NODE_CLASS_MAPPINGS = {"NeuradVisualLora": VisualLoraLoaderNode} +NODE_DISPLAY_NAME_MAPPINGS = {"NeuradVisualLora": "Visual LoRA Loader - Neurad"} \ No newline at end of file diff --git a/web/config.js b/web/config.js new file mode 100644 index 0000000..90c204c --- /dev/null +++ b/web/config.js @@ -0,0 +1,66 @@ +// ============================================================================ +// CONFIGURATION & CONSTANTS +// ============================================================================ +export const CONFIG = { + // Dimensions + CARD_WIDTH: 140, + CARD_HEIGHT: 230, + CARD_HEIGHT_PX: "230px", + IMG_HEIGHT: 160, + TAB_HEIGHT: 28, + + // Behavior + STRENGTH_STEP: 0.05, + CACHE_SAVE_DELAY_MS: 1000, + IMAGE_CACHE_MAX_ITEMS: 800, + + // Colors (ComfyUI Native Palette) + COLORS: { + // --- Backgrounds --- + bg_dark: '#222222', + bg_panel: '#353535', + bg_header: '#333333', + bg_toggle: '#3b3b3b', + bg_toggle_faded: '#4c4c4c', + bg_card_inactive: '#444444', + bg_card_active: '#5577bb', + bg_card_hover: '#666666', + bg_card_active_hover: '#7799dd', + bg_button: '#666666', + bg_button_hover: '#a1a1a1', + bg_tab_active: '#353535', + bg_tab_hover: '#2b2b2b', + bg_close_hover: '#bb6666', + + // --- Borders & Separators --- + border_default: '#666666', + border_active: '#5577bb', + border_moving_marker: '#2266ee', + border_error: '#ff6b6b', + border_negative: '#885555', + separator_header: '#2c2c2c', + border_bright_blue: '#4488ff', + + // --- Text --- + text_main: '#dddddd', + text_node_title: '#ffffff', + text_field_title: '#999999', + text_faded: '#6c6c6c', + text_negative: '#bb6666', + text_links: '#aaaaff', + text_error: '#ffaaaa', + + // --- Accents & Status --- + accent_blue_faded: '#8899bb', //button hover + accent_blue_select: '#5577bb', //button + dot_off: '#888888', + dot_off_dark: '#333333', + + // --- Scrollbars --- + scroll_track: '#222222', + scroll_thumb: '#666666', + scroll_hover: '#999999' + } +}; + +//${CONFIG.COLORS.} \ No newline at end of file diff --git a/web/visual_lora_loader.js b/web/visual_lora_loader.js new file mode 100644 index 0000000..e41b681 --- /dev/null +++ b/web/visual_lora_loader.js @@ -0,0 +1,2618 @@ +import { app } from "../../scripts/app.js"; + +import { CONFIG } from "./config.js"; +console.log("[Neurad] VisualLora extension loaded."); + +// ============================================================================ +// LOCAL STORAGE CACHE WITH DISK BACKUP +// ============================================================================ +// Key used to identify our data in the browser's LocalStorage. +const STORAGE_KEY = "neurad_lora_cache_v1"; + +// Global state variable to track the current Drag & Drop operation. +// Possible values: null, 'tab-reorder', or 'lora-card' +let neuradDragState = null; + +/** + * LocalCache Module + * + * Manages a dual-layer persistence system for LoRA metadata: + * 1. Primary: Browser LocalStorage (Fast access, session persistence). + * 2. Backup: Server-side Disk Storage (Disaster recovery if LocalStorage is cleared). + * + * Logic: + * - On Load: Reads from LocalStorage. If empty, attempts to restore from Server Disk. + * - On Save: Writes to LocalStorage immediately, then debounces a request to save to Server Disk. + */ +const LocalCache = { + data: {}, + saveTimeout: null, + + /** + * Loads data from the browser's LocalStorage into memory. + * Handles parsing errors gracefully. + */ + load() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) this.data = JSON.parse(stored); + } catch (e) { console.error("[Neurad] LocalStorage load error", e); } + }, + + /** + * Saves current memory data to LocalStorage and triggers an asynchronous disk backup. + * The disk backup is debounced (delayed) to prevent excessive server requests during rapid updates. + */ + save() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data)); + } catch (e) { console.error("[Neurad] LocalStorage save error", e); } + + // Trigger disk backup with a 1-second delay to batch changes + this.triggerDiskSave(); + }, + + /** Retrieves a specific item from the cache by key. */ + get(key) { return this.data[key] || null; }, + + /** + * Updates an item in the cache and triggers a save. + * @param {string} key - The LoRA path identifier. + * @param {object} value - The metadata object to store. + */ + set(key, value) { + this.data[key] = value; + this.save(); + }, + + /** + * Debounced server synchronization. + * Sends the current cache state to the Python backend to be written to the physical disk. + * Clears any pending timeout to ensure only the latest state is saved. + */ + triggerDiskSave() { + if (this.saveTimeout) clearTimeout(this.saveTimeout); + this.saveTimeout = setTimeout(() => { + fetch('/neurad/save-localstorage-backup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.data) + }).catch(e => console.warn("[Neurad] Disk backup failed", e)); + }, 1000); + }, + + /** + * Force restores data from the server disk backup. + * Used when LocalStorage is empty or corrupted. + * Overwrites the current memory and LocalStorage with the server's version. + */ + async restoreFromDisk() { + console.log("[Neurad] Starting restore process..."); + + try { + const resp = await fetch('/neurad/load-localstorage-backup'); + const result = await resp.json(); + + if (result.success && result.data && Object.keys(result.data).length > 0) { + // Overwrite local memory with server data + this.data = result.data; + + // Sync back to LocalStorage for the current session + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data)); + console.log("[Neurad] LocalStorage updated from disk."); + } catch(e) { + console.warn("[Neurad] Could not write to LocalStorage (Desktop limitation)."); + } + + console.log(`[Neurad] SUCCESS: Restored ${Object.keys(this.data).length} items from disk backup.`); + } else { + console.log("[Neurad] No disk backup found."); + } + } catch (e) { + console.error("[Neurad] Disk restore failed critically", e); + } + } +}; + +// Initialize cache immediately upon script load +LocalCache.load(); + +// ============================================================================ +// GLOBAL IMAGE CACHE +// ============================================================================ +/** + * NeuradImageCache + * + * An in-memory cache for HTMLImageElement objects to prevent redundant network requests + * and improve rendering performance when scrolling through large LoRA catalogs. + * + * Strategy: FIFO (First-In-First-Out) eviction when the cache exceeds maxItems. + * Note: Images are cloned when retrieved to avoid detaching them from the cache if they are added to the DOM. + */ +const NeuradImageCache = { + store: {}, // Key: Image URL, Value: Cloned HTMLImageElement + maxItems: 800, // Limit to prevent memory leaks + + /** Retrieves a cached image element, or null if not found. */ + get(url) { + return this.store[url] || null; + }, + + /** + * Stores a loaded image element. + * If the cache is full, the oldest entry is removed. + * The element is cloned before storage to preserve the original reference. + */ + set(url, imgElement) { + const keys = Object.keys(this.store); + if (keys.length >= this.maxItems) { + delete this.store[keys[0]]; + } + this.store[url] = imgElement.cloneNode(true); + } +}; + +// ============================================================================ +// GLOBAL STYLES INJECTION +// ============================================================================ +/** + * Injects global CSS styles required for the extension's UI components. + * Specifically handles Tab styling (active/inactive states) and custom Scrollbars. + * Ensures styles are only injected once by checking for the existing style ID. + */ +function injectGlobalStyles() { + if (document.getElementById('neurad-global-styles')) return; + + const style = document.createElement('style'); + style.id = 'neurad-global-styles'; + style.textContent = ` + /* Tab Styles */ + .neurad-tab { /* inactive */ + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px 6px 10px; + font-size: 13px; + font-weight: 300; + cursor: pointer; + border-radius: 4px; + margin: 4px 2px 4px 0px; + background: transparent; color: ${CONFIG.COLORS.text_faded}; z-index: 1; white-space: nowrap; + } + .neurad-tab:hover { background: ${CONFIG.COLORS.bg_tab_hover}; color: ${CONFIG.COLORS.text_main}; } + .neurad-tab.active { background: ${CONFIG.COLORS.bg_tab_active} !important; color: ${CONFIG.COLORS.text_main}; font-weight: 300; z-index: 10; } + + /* Image Scrollbar */ + .neurad-image-scroll::-webkit-scrollbar { height: 8px; } + .neurad-image-scroll::-webkit-scrollbar-track { background: ${CONFIG.COLORS.bg_dark}; } + .neurad-image-scroll::-webkit-scrollbar-thumb { background: ${CONFIG.COLORS.bg_button}; border-radius: 4px; } + .neurad-image-scroll::-webkit-scrollbar-thumb:hover { background: ${CONFIG.COLORS.bg_button_hover}; } + `; + document.head.appendChild(style); +} +injectGlobalStyles(); + +// ============================================================================ +// CORE UTILITIES +// ============================================================================ + +/** + * Persists the current tab configuration to the node's properties. + * This ensures the state is saved when the workflow is saved. + * @param {Object} nodeRef - The ComfyUI node instance. + */ +function saveTabsConfig(nodeRef) { + if (!nodeRef.properties.tabsConfig) { + console.error("[Neurad] Error: tabsConfig missing!"); + return; + } + // Implicitly saved via node properties serialization +} + +/** + * Creates a new custom tab. + * @param {Object} nodeRef - The node instance. + * @param {string} name - The display name for the new tab. + * @returns {Object} The newly created tab object. + */ +function createNewTab(nodeRef, name = "New Tab") { + const newId = "tab_" + Date.now(); + const newTab = { id: newId, name: name, locked: false, type: "custom", loras: [] }; + nodeRef.properties.tabsConfig.tabs.push(newTab); + saveTabsConfig(nodeRef); + return newTab; +} + +/** + * Removes a tab by ID. + * Prevents deletion of the locked "Library" tab. + * Switches active focus to "library" if the deleted tab was active. + * @returns {boolean} True if successful, false otherwise. + */ +function closeTab(nodeRef, tabId) { + const config = nodeRef.properties.tabsConfig; + const tabIndex = config.tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return false; + + const tab = config.tabs[tabIndex]; + if (tab.locked) { alert("Cannot delete the 'Library' tab."); return false; } + + config.tabs.splice(tabIndex, 1); + if (config.activeTabId === tabId) config.activeTabId = "library"; + + saveTabsConfig(nodeRef); + return true; +} + +/** + * Adds a LoRA path to a specific tab's list. + * Prevents duplicates and modification of locked tabs. + * @returns {boolean} True if added, false otherwise. + */ +function addLoraToTab(nodeRef, tabId, loraPath) { + const config = nodeRef.properties.tabsConfig; + const tab = config.tabs.find(t => t.id === tabId); + if (!tab || tab.locked) return false; + if (tab.loras.includes(loraPath)) return false; + + tab.loras.push(loraPath); + saveTabsConfig(nodeRef); + return true; +} + +/** + * Removes a LoRA path from a specific tab's list. + * @returns {boolean} True if removed, false otherwise. + */ +function removeLoraFromTab(nodeRef, tabId, loraPath) { + const config = nodeRef.properties.tabsConfig; + const tab = config.tabs.find(t => t.id === tabId); + if (!tab || tab.locked) return false; + + const index = tab.loras.indexOf(loraPath); + if (index > -1) { + tab.loras.splice(index, 1); + saveTabsConfig(nodeRef); + return true; + } + return false; +} + +/** + * Filters the full LoRA catalog based on the active tab and search criteria. + * + * Logic: + * 1. Determines the base list (Full catalog for "Library"/dynamic tabs, or specific list for custom tabs). + * 2. Applies Text Filters (Include/Exclude queries). + * - Auto-excludes "nsfw" unless explicitly requested in the include query. + * 3. Applies State Filters (On/Off/Has Meta/No Meta). + * - Checks both server metadata and local cache for "has_meta" status. + * + * @param {Array} catalog - The full list of LoRA objects. + * @param {Object} nodeRef - The node instance. + * @param {string} tabId - The ID of the active tab. + * @returns {Array} The filtered list of LoRA objects. + */ +function getLorasForTab(catalog, nodeRef, tabId) { + if (!catalog) return []; + const config = nodeRef.properties.tabsConfig; + const searchConfig = nodeRef.properties.searchConfig; + + if (!config || !searchConfig) return []; + if (!searchConfig.filters || !Array.isArray(searchConfig.filters)) { + searchConfig.filters = []; + } + + const tab = config.tabs.find(t => t.id === tabId); + if (!tab) return []; + + // Determine base list source + let baseList = []; + if (tab.type === "dynamic" || tab.id === "library") { + baseList = catalog; + } else { + for (const loraPath of tab.loras) { + const loraObj = catalog.find(l => l.relative_path === loraPath); + if (loraObj) baseList.push(loraObj); + } + } + + // Process Search Queries + let query = searchConfig.query.trim().toLowerCase(); + let excludeQuery = (searchConfig.excludeQuery || "").trim().toLowerCase(); + + // Auto-filter NSFW: If "nsfw" is not in the positive query, add it to exclusions + const isNsfwRequested = query.includes("nsfw"); + if (!isNsfwRequested) { + if (excludeQuery === "") { + excludeQuery = "nsfw"; + } else { + const excludeWords = excludeQuery.split(/\s+/).filter(w => w); + if (!excludeWords.includes("nsfw")) { + excludeQuery += " nsfw"; + } + } + } + + const activeFilters = searchConfig.filters; + if (!searchConfig.filters || !Array.isArray(searchConfig.filters)) { + searchConfig.filters = []; + } + + // Return early if no filters are active + if (!query && !excludeQuery && activeFilters.length === 0) return baseList; + + return baseList.filter(lora => { + // 1. Check Cache for metadata status + const loraKey = lora.relative_path; + const cachedMeta = (typeof LocalCache !== 'undefined') ? LocalCache.get(loraKey) : null; + + // 2. Determine real 'has_meta' status (Server OR Cache) + let realHasMeta = false; + if (lora.has_meta && lora.meta && Object.keys(lora.meta).length > 0) { + realHasMeta = true; + } else if (cachedMeta) { + realHasMeta = true; + } + + // Determine best display name (Cache > Meta > Default) + let bestName = lora.display_name || ""; + if (lora.has_meta && lora.meta && lora.meta.name) bestName = lora.meta.name; + if (cachedMeta && cachedMeta.name) bestName = cachedMeta.name; + + // Filter 1: Text Match (Include) + if (query) { + const words = query.split(/\s+/); + const searchableText = (bestName + " " + (lora.relative_path || "") + " " + (lora.subfolder || "")).toLowerCase(); + const textMatch = words.every(word => searchableText.includes(word)); + if (!textMatch) return false; + } + + // Filter 2: Text Match (Exclude) + if (excludeQuery) { + const excludeWords = excludeQuery.split(/\s+/); + const searchableText = (bestName + " " + (lora.relative_path || "") + " " + (lora.subfolder || "")).toLowerCase(); + // If any excluded word is found, reject the item + const excludeMatch = !excludeWords.some(word => word && searchableText.includes(word)); + if (!excludeMatch) return false; + } + + // Filter 3: State Filters (On/Off/Meta) + for (let i = 0; i < activeFilters.length; i++) { + const filter = activeFilters[i]; + const currentMap = nodeRef.properties.activeLorasMap || {}; + const isActive = (currentMap[loraKey] || {}).on || false; + + if (filter === "on" && !isActive) return false; + if (filter === "off" && isActive) return false; + + // Use the computed 'realHasMeta' status for filtering + if (filter === "has_meta" && !realHasMeta) return false; + if (filter === "no_meta" && realHasMeta) return false; + } + + return true; + }); +} + +// ============================================================================ +// UI RENDERING: SEARCH BAR +// ============================================================================ +/** + * Renders the search bar component, including: + * 1. Positive search input (Include terms). + * 2. Negative search input (Exclude terms). + * 3. Dynamic filter buttons (On, Off, Has Meta, No Meta). + * + * Features: + * - Auto-clears buttons visibility based on input state. + * - Handles mutual exclusivity for toggle filters (On vs Off, Has Meta vs No Meta). + * - Applies visual styling based on active/inactive states. + * - Auto-focuses the last active input field upon render. + * + * @param {Object} nodeRef - The node instance. + * @param {HTMLElement} container - The parent DOM element to append the search bar to. + * @param {Array} fullCatalog - The full LoRA catalog (used for context). + * @param {Function} onSearchChange - Callback triggered when search criteria change. + */ +function renderSearchBar(nodeRef, container, fullCatalog, onSearchChange) { + const config = nodeRef.properties.searchConfig; + if (!config) return; + + // Ensure filters array exists + if (!config.filters || !Array.isArray(config.filters)) { + config.filters = []; + } + + const searchBar = document.createElement("div"); + searchBar.classList.add('neurad-search-bar'); + searchBar.style.cssText = `display:flex; flex-direction:column; gap:8px; padding: 10px; padding-top: 0px; background:${CONFIG.COLORS.bg_panel}; border-bottom:1px solid ${CONFIG.COLORS.separator_header}; flex-shrink:0;`; + + // --- ROW 1: Positive Search Input --- + const row1 = document.createElement("div"); + row1.style.cssText = "display:flex; align-items:center; gap:8px;"; + + const inputPos = document.createElement("input"); + inputPos.type = "text"; + inputPos.value = config.query; + inputPos.placeholder = "Include (name, file, folder)..."; + inputPos.style.cssText = `flex:1; padding:6px 10px; background:${CONFIG.COLORS.bg_dark}; border:1px solid ${CONFIG.COLORS.border_default}; border-radius:8px; color:${CONFIG.COLORS.text_main}; font-size:13px; outline:none;`; + + // Update config on input + inputPos.oninput = function() { + config.query = inputPos.value; + config.lastActiveField = "positive"; + if (onSearchChange) onSearchChange(); + }; + + // Clear button for Positive Input + const clearBtnPos = document.createElement("div"); + const textSpanPos = document.createElement("span"); + textSpanPos.innerText = "✕"; + clearBtnPos.appendChild(textSpanPos); + + // Button styling (Centered square) + const btnStylePos = ` + width: 29px; height: 29px; min-width: 29px; + background: transparent; cursor: pointer; + display: flex; justify-content: center; align-items: center; + border-radius: 4px; padding: 0; + `; + // Text styling (Slight vertical offset for visual alignment) + const textStylePos = ` + font-size: 16px; color: ${CONFIG.COLORS.text_main}; + display: flex; justify-content: center; align-items: center; + transform: translateY(-1px); + `; + + clearBtnPos.style.cssText = btnStylePos; + textSpanPos.style.cssText = textStylePos; + clearBtnPos.style.display = config.query ? "flex" : "none"; + + clearBtnPos.onmouseover = function() { this.style.background = CONFIG.COLORS.bg_close_hover; }; + clearBtnPos.onmouseout = function() { this.style.background = "transparent"; }; + clearBtnPos.onclick = function() { + config.query = ""; inputPos.value = ""; + clearBtnPos.style.display = "none"; + config.lastActiveField = "positive"; + if (onSearchChange) onSearchChange(); + }; + + row1.appendChild(inputPos); + row1.appendChild(clearBtnPos); + + // --- ROW 2: Negative Search + Filters --- + const row2 = document.createElement("div"); + row2.style.cssText = "display:flex; align-items:center; gap:8px; flex-wrap: wrap;"; + + // Negative Input + const inputNeg = document.createElement("input"); + inputNeg.type = "text"; + inputNeg.value = config.excludeQuery || ""; + inputNeg.placeholder = "Exclude..."; + inputNeg.style.cssText = `flex:1; padding:6px 10px; background:${CONFIG.COLORS.bg_dark}; border:1px solid ${CONFIG.COLORS.border_negative}; border-radius:8px; color:${CONFIG.COLORS.text_negative}; font-size:13px; outline:none;`; + inputNeg.oninput = function() { + config.excludeQuery = inputNeg.value; + config.lastActiveField = "negative"; + if (onSearchChange) onSearchChange(); + }; + + // Clear button for Negative Input + const clearBtnNeg = document.createElement("div"); + const textSpanNeg = document.createElement("span"); + textSpanNeg.innerText = "✕"; + clearBtnNeg.appendChild(textSpanNeg); + + const btnStyleNeg = ` + width: 29px; height: 29px; min-width: 29px; + background: transparent; cursor: pointer; + display: flex; justify-content: center; align-items: center; + border-radius: 4px; padding: 0; + `; + const textStyleNeg = ` + font-size: 16px; color: ${CONFIG.COLORS.text_main}; + display: flex; justify-content: center; align-items: center; + transform: translateY(-1px); + `; + + clearBtnNeg.style.cssText = btnStyleNeg; + textSpanNeg.style.cssText = textStyleNeg; + clearBtnNeg.style.display = config.excludeQuery ? "flex" : "none"; + + clearBtnNeg.onmouseover = function() { this.style.background = CONFIG.COLORS.bg_close_hover; }; + clearBtnNeg.onmouseout = function() { this.style.background = "transparent"; }; + clearBtnNeg.onclick = function() { + config.excludeQuery = ""; inputNeg.value = ""; + clearBtnNeg.style.display = "none"; + config.lastActiveField = "negative"; + if (onSearchChange) onSearchChange(); + }; + + row2.appendChild(inputNeg); + row2.appendChild(clearBtnNeg); + + /** + * Helper to create filter buttons with mutual exclusivity logic. + * @param {string} label - Text label (if no icon). + * @param {string} icon - Icon character (if applicable). + * @param {string} filterValue - The filter ID (e.g., "on", "has_meta"). + */ + const createFilterBtn = function(label, icon, filterValue) { + const btn = document.createElement("div"); + const isActive = config.filters.indexOf(filterValue) > -1; + + // Determine colors based on active state + let borderColor = CONFIG.COLORS.border_default; + let textColor = CONFIG.COLORS.text_main; + let iconFilter = "none"; + + if (isActive) { + borderColor = CONFIG.COLORS.border_active; + } + if (filterValue === "no_meta"){ + iconFilter = "grayscale(100%)"; + } + + // Base Button Style + btn.style.cssText = ` + width: 29px; height: 29px; min-width: 29px; + background: transparent; border: 1px solid ${borderColor}; + color: ${textColor}; border-radius: 4px; + cursor: pointer; display: flex; + justify-content: center; align-items: center; + user-select: none; padding: 0; margin-right: 4px; + `; + + // Content (Icon or Text) + const contentSpan = document.createElement("span"); + contentSpan.innerText = icon ? icon : label; + contentSpan.style.cssText = ` + font-weight: bold; font-size: ${icon ? "14px" : "11px"}; + display: flex; justify-content: center; align-items: center; + filter: ${iconFilter}; line-height: 1; + `; + btn.appendChild(contentSpan); + + // Click Handler with Mutual Exclusivity Logic + btn.onclick = function(e) { + e.stopPropagation(); + let newFilters = [...config.filters]; + const idx = newFilters.indexOf(filterValue); + + if (filterValue === "on") { + if (idx === -1) { + newFilters = newFilters.filter(f => f !== "off"); + newFilters.push("on"); + } else { + newFilters.splice(idx, 1); + } + } else if (filterValue === "off") { + if (idx === -1) { + newFilters = newFilters.filter(f => f !== "on"); + newFilters.push("off"); + } else { + newFilters.splice(idx, 1); + } + } else if (filterValue === "has_meta") { + if (idx === -1) { + newFilters = newFilters.filter(f => f !== "no_meta"); + newFilters.push("has_meta"); + } else { + newFilters.splice(idx, 1); + } + } else if (filterValue === "no_meta") { + if (idx === -1) { + newFilters = newFilters.filter(f => f !== "has_meta"); + newFilters.push("no_meta"); + } else { + newFilters.splice(idx, 1); + } + } else { + // Standard toggle + if (idx > -1) newFilters.splice(idx, 1); + else newFilters.push(filterValue); + } + + config.filters = newFilters; + config.lastActiveField = "positive"; + if (onSearchChange) onSearchChange(); + }; + + // Hover Effects + btn.onmouseover = function() { + this.style.background = CONFIG.COLORS.bg_toggle_faded; + this.style.cursor = "pointer"; + }; + btn.onmouseout = function() { + this.style.background = "transparent"; + this.style.borderColor = isActive ? CONFIG.COLORS.border_active : CONFIG.COLORS.border_default; + }; + + return btn; + }; + + // Create and append filter buttons + row2.appendChild(createFilterBtn("ON", null, "on")); + row2.appendChild(createFilterBtn("OFF", null, "off")); + row2.appendChild(createFilterBtn(null, "ℹ️", "has_meta")); + row2.appendChild(createFilterBtn(null, "ℹ️", "no_meta")); + + searchBar.appendChild(row1); + searchBar.appendChild(row2); + container.appendChild(searchBar); + + // Auto-focus logic: Restore cursor to the last active input field + setTimeout(function() { + const activeField = config.lastActiveField || "positive"; + const targetInput = activeField === "negative" ? inputNeg : inputPos; + if(targetInput) { + targetInput.focus(); + const val = targetInput.value; + targetInput.value = ''; + targetInput.value = val; // Trick to ensure cursor is at end + } + }, 10); +} + +// --- HELPER: Compute LoRA counts for a specific tab --- +/** + * Calculates the number of active ("On") and inactive ("Off") LoRAs within a specific tab. + * Used to display the [On/Off] badges on tab buttons. + * + * @param {Object} tab - The tab object. + * @param {Array} currentCatalog - The full list of LoRAs. + * @param {Object} activeMap - The map of currently active LoRA states. + * @returns {Object} { onCount: number, offCount: number } + */ +function computeTabCounts(tab, currentCatalog, activeMap) { + let onCount = 0, offCount = 0; + + // Determine which LoRAs belong to this tab + const lorasInThisTab = (tab.id === "library" || tab.type === "dynamic") + ? currentCatalog + : currentCatalog.filter(l => l && l.relative_path && tab.loras.includes(l.relative_path)); + + lorasInThisTab.forEach(lora => { + if (!lora || !lora.relative_path) return; + const state = activeMap[lora.relative_path]; + if (state && state.on) onCount++; + else offCount++; + }); + return { onCount, offCount }; +} + +// --- HELPER: Create HTML fragment for a count badge --- +/** + * Generates a DOM fragment for a single count badge (e.g., "[5]"). + * Styles the number bold if count > 0. + * + * @param {number} count - The number to display. + * @param {string} color - The CSS color for the text. + * @returns {DocumentFragment} + */ +function createCounterPart(count, color) { + const frag = document.createDocumentFragment(); + + const open = document.createElement("span"); + open.innerText = "["; + open.style.cssText = `font-size:13px; color:${color}; line-height:1; vertical-align:top; margin-left:4px;`; + + const num = document.createElement("span"); + num.innerText = count; + num.style.cssText = `font-size:10px; color:${color}; font-weight:${count>0?'bold':'normal'}; vertical-align:top; margin-top:2px;`; + + const close = document.createElement("span"); + close.innerText = "]"; + close.style.cssText = `font-size:13px; color:${color}; line-height:1; vertical-align:top;`; + + frag.append(open, num, close); + return frag; +} + +// --- HELPER: Update a single tab counter in place --- +/** + * Updates the counter badge of a specific tab button without re-rendering the entire tab bar. + * Optimizes performance during drag-and-drop operations or state changes. + * + * @param {Object} nodeRef - The node instance. + * @param {HTMLElement} tabBtn - The specific tab button DOM element. + * @param {Object} tab - The tab data object. + * @param {Array} currentCatalog - The full LoRA catalog. + */ +function updateTabCounterInPlace(nodeRef, tabBtn, tab, currentCatalog) { + const counterSpan = tabBtn.querySelector('.neurad-tab-counter'); + if (!counterSpan) return; + + const activeMap = nodeRef.properties.activeLorasMap || {}; + const { onCount, offCount } = computeTabCounts(tab, currentCatalog, activeMap); + + counterSpan.innerHTML = ''; + counterSpan.appendChild(createCounterPart(onCount, CONFIG.COLORS.bg_card_active)); + counterSpan.appendChild(createCounterPart(offCount, CONFIG.COLORS.text_faded)); +} + +// ============================================================================ +// UI RENDERING: TABS BAR +// ============================================================================ +/** + * Renders the horizontal tab bar. + * Features: + * - Displays tabs with active/inactive states and LoRA count badges. + * - Supports renaming (double-click), deletion (x button), and creation (+ button). + * - Implements Drag & Drop for reordering tabs. + * - Implements Drop Zone logic for accepting LoRA cards from other tabs. + * - Maintains scroll position during refreshes. + * + * @param {Object} nodeRef - The node instance. + * @param {HTMLElement} container - Parent DOM element. + * @param {Array} currentCatalog - The full LoRA catalog. + * @param {Function} onTabChange - Callback when active tab changes. + * @param {Function} refreshCallback - Callback to refresh the UI. + * @param {number} scrollPosition - Horizontal scroll offset to restore. + */ +function renderTabsBar(nodeRef, container, currentCatalog, onTabChange, refreshCallback, scrollPosition = 0) { + + const config = nodeRef.properties.tabsConfig; + if (!config || !currentCatalog) { + console.warn("[Neurad] renderTabsBar aborted: Missing config or catalog."); + return; + } + + // Clean up existing bar + const existingBar = container.querySelector('.neurad-tab-bar'); + if (existingBar) existingBar.remove(); + + const tabBar = document.createElement("div"); + tabBar.classList.add('neurad-tab-bar'); + tabBar.style.cssText = `display:flex; align-items:flex-start; gap:2px; padding:0px 10px 0 10px; background:${CONFIG.COLORS.bg_dark}; border-bottom:1px solid ${CONFIG.COLORS.separator_header}; flex-shrink:0; overflow-x:auto; white-space:nowrap; scrollbar-width:thin; scrollbar-color:${CONFIG.COLORS.bg_button} ${CONFIG.COLORS.bg_dark}; position: relative;`; + + // Restore scroll position + requestAnimationFrame(() => { tabBar.scrollLeft = scrollPosition; }); + + // Ensure at least the Library tab exists + if (!config.tabs || config.tabs.length === 0) { + config.tabs = [{ id: "library", name: "Library", locked: true, type: "dynamic", loras: [] }]; + config.activeTabId = "library"; + } + + config.tabs.forEach((tab, tabIndex) => { + const isActive = tab.id === config.activeTabId; + const tabBtn = document.createElement("div"); + tabBtn.innerText = tab.name; + tabBtn.classList.add('neurad-tab'); + if (isActive) tabBtn.classList.add('active'); + tabBtn.style.position = "relative"; + + // --- COUNTER BADGES --- + const activeMap = nodeRef.properties.activeLorasMap || {}; + const { onCount, offCount } = computeTabCounts(tab, currentCatalog, activeMap); + + const counterSpan = document.createElement("span"); + counterSpan.classList.add('neurad-tab-counter'); + counterSpan.style.cssText = "opacity:1; display:inline-flex; gap:2px; padding: 0px 23px 3px 0px; margin-left:-2px;"; + counterSpan.appendChild(createCounterPart(onCount, CONFIG.COLORS.bg_card_active)); + counterSpan.appendChild(createCounterPart(offCount, CONFIG.COLORS.text_faded)); + tabBtn.appendChild(counterSpan); + + // --- EVENTS: Rename & Activate --- + tabBtn.ondblclick = (e) => { + e.stopPropagation(); + const newName = prompt("New tab name:", tab.name); + if (newName && newName.trim() !== "") { + tab.name = newName.trim(); + saveTabsConfig(nodeRef); + if (onTabChange) onTabChange(); + } + }; + + tabBtn.onclick = () => { + if (config.activeTabId !== tab.id) { + config.activeTabId = tab.id; + saveTabsConfig(nodeRef); + if (onTabChange) onTabChange(); + } + }; + + // --- CLOSE BUTTON (Only for non-locked tabs) --- + if (!tab.locked) { + const closeBtn = document.createElement("span"); + closeBtn.innerText = "✕"; + closeBtn.style.cssText = ` + font-size: 14px; position: absolute; right: 0px; top: 0; bottom: 0; + width: 28px; background: transparent; border: none; + border-radius: 4px; cursor: pointer; padding: 0px 0px 1px 0px; + margin: 0; display: flex; align-items: center; + justify-content: center; z-index: 10; + `; + + // Dynamic color logic: Light gray if active or hovered, dark gray otherwise + const updateCloseBtnStyle = () => { + const isTabActive = (tab.id === config.activeTabId); + const isBtnHovered = closeBtn.matches(':hover'); + if (isBtnHovered) { + closeBtn.style.color = CONFIG.COLORS.text_node_title; // Light + } else { + closeBtn.style.color = CONFIG.COLORS.text_faded; // Dark + } + }; + + closeBtn.onmouseenter = (e) => { + e.stopPropagation(); + closeBtn.style.backgroundColor = CONFIG.COLORS.bg_close_hover; + updateCloseBtnStyle(); + }; + closeBtn.onmouseleave = (e) => { + e.stopPropagation(); + closeBtn.style.backgroundColor = "transparent"; + updateCloseBtnStyle(); + }; + tabBtn.onmouseenter = (e) => { updateCloseBtnStyle(); }; + tabBtn.onmouseleave = (e) => { updateCloseBtnStyle(); }; + + const originalOnClick = tabBtn.onclick; + tabBtn.onclick = (e) => { + if (originalOnClick) originalOnClick(e); + setTimeout(updateCloseBtnStyle, 10); + }; + updateCloseBtnStyle(); // Init + + closeBtn.onclick = (e) => { + e.stopPropagation(); + if (confirm(`Delete tab "${tab.name}"?`)) { + closeTab(nodeRef, tab.id); + if (onTabChange) onTabChange(); + } + }; + tabBtn.appendChild(closeBtn); + } + + // --- DRAG & DROP LOGIC (Unified for Tabs and Cards) --- + if (!tab.locked) { + tabBtn.draggable = true; + let currentMarkerSide = null; + + // Helper: Reset all visual drag indicators on a button + const resetVisuals = (btn) => { + if (!btn) return; + // Reset Tab Drag styles + btn.style.borderLeft = ""; btn.style.borderRight = ""; + btn.style.marginLeft = ""; btn.style.marginRight = ""; + btn.style.boxSizing = ""; + // Reset Card Drop styles + btn.style.background = ""; + // Reset Opacity + if (btn.style.opacity === "0.6") { + btn.style.opacity = (btn.classList.contains('active')) ? "1" : ""; + } + }; + + // 1. DRAG START (Tab Reorder) + tabBtn.ondragstart = (e) => { + if (e.target !== tabBtn) { e.preventDefault(); return; } + e.stopPropagation(); + if (typeof neuradDragState !== 'undefined') neuradDragState = "tab-reorder"; + + e.dataTransfer.setData("application/x-neurad-tab-reorder", tabIndex.toString()); + e.dataTransfer.setData("application/json", JSON.stringify({ + type: "tab_reorder", dragType: "tab-reorder", index: tabIndex, tabId: tab.id + })); + e.dataTransfer.effectAllowed = "move"; + tabBtn.style.opacity = "0.4"; + }; + + // 2. DRAG END (Cleanup) + tabBtn.ondragend = () => { + if (typeof neuradDragState !== 'undefined') neuradDragState = null; + resetVisuals(tabBtn); + currentMarkerSide = null; + }; + + // 3. DRAG ENTER (Global Cleaner & Preview) + tabBtn.ondragenter = (e) => { + e.preventDefault(); e.stopPropagation(); + const types = e.dataTransfer.types; + const isDragTab = types.includes("application/x-neurad-tab-reorder"); + const isDragCard = types.includes("application/json") && !isDragTab; + + if (isDragTab || isDragCard) { + // Clear visuals on ALL other tabs + const allTabs = tabBar.querySelectorAll('.neurad-tab'); + allTabs.forEach(t => { if (t !== tabBtn) resetVisuals(t); }); + + // Set preview style based on drag type + if (isDragTab) { + tabBtn.style.opacity = "0.6"; tabBtn.style.background = ""; + } else if (isDragCard) { + tabBtn.style.background = CONFIG.COLORS.bg_card_active_hover; + tabBtn.style.opacity = ""; + } + } + }; + + // 4. DRAG OVER (Position Marker for Tabs, Highlight for Cards) + tabBtn.ondragover = (e) => { + e.preventDefault(); e.stopPropagation(); + const types = e.dataTransfer.types; + const isDragTab = types.includes("application/x-neurad-tab-reorder"); + const isDragCard = types.includes("application/json") && !isDragTab; + + if (isDragCard) { + // Card Drop: Ensure background is set, no borders + tabBtn.style.borderLeft = ""; tabBtn.style.borderRight = ""; + tabBtn.style.marginLeft = ""; tabBtn.style.marginRight = ""; + tabBtn.style.background = CONFIG.COLORS.bg_card_active_hover; + } + else if (isDragTab) { + // Tab Reorder: Show left/right insertion marker + const rect = tabBtn.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const newSide = (e.clientX < midX) ? 'left' : 'right'; + + if (currentMarkerSide !== newSide) { + currentMarkerSide = newSide; + // Reset + tabBtn.style.borderLeft = ""; tabBtn.style.borderRight = ""; + tabBtn.style.marginLeft = ""; tabBtn.style.marginRight = ""; + tabBtn.style.boxSizing = "content-box"; + + if (newSide === 'left') { + tabBtn.style.borderLeft = "3px solid #4da6ff"; + tabBtn.style.marginLeft = "-3px"; + } else { + tabBtn.style.borderRight = "3px solid #4da6ff"; + tabBtn.style.marginRight = "-3px"; + } + } + } + else { resetVisuals(tabBtn); } + }; + + // 5. DRAG LEAVE (Self Cleanup) + tabBtn.ondragleave = (e) => { + if (e.target === tabBtn) { + resetVisuals(tabBtn); + currentMarkerSide = null; + } + }; + + // 6. DROP (Handle Reorder or Card Add) + tabBtn.ondrop = (e) => { + e.preventDefault(); e.stopPropagation(); + resetVisuals(tabBtn); currentMarkerSide = null; + const types = e.dataTransfer.types; + + // Case A: Tab Reorder + if (types.includes("application/x-neurad-tab-reorder")) { + try { + const data = e.dataTransfer.getData("application/json"); + if (!data) return; + const payload = JSON.parse(data); + + if (payload.dragType === "tab-reorder" && typeof payload.index === 'number') { + const sourceIndex = payload.index; + let targetIndex = tabIndex; + + const rect = tabBtn.getBoundingClientRect(); + if (e.clientX >= rect.left + rect.width / 2) { targetIndex = targetIndex + 1; } + + if (sourceIndex >= 0 && sourceIndex < config.tabs.length) { + const movedTab = config.tabs.splice(sourceIndex, 1)[0]; + if (sourceIndex < targetIndex) targetIndex = targetIndex - 1; + targetIndex = Math.max(0, Math.min(targetIndex, config.tabs.length)); + config.tabs.splice(targetIndex, 0, movedTab); + saveTabsConfig(nodeRef); + if (onTabChange) onTabChange(); + } + } + } catch (err) { console.error("[Neurad] Tab reorder drop error:", err); } + } + // Case B: Card Drop (Add LoRA to this tab) + else if (types.includes("application/json")) { + try { + const data = e.dataTransfer.getData("application/json"); + if (!data) return; + const payload = JSON.parse(data); + + if (payload.dragType === "lora-card" && payload.loraPath && payload.sourceTabId) { + const targetTabId = tab.id; + if (payload.sourceTabId !== targetTabId) { + const added = addLoraToTab(nodeRef, targetTabId, payload.loraPath); + if (added) { + requestAnimationFrame(() => { + if (targetTabId === config.activeTabId) { + // Refresh entire grid if dropping on active tab + if (refreshCallback) refreshCallback(); + else renderTabsBar(nodeRef, container, currentCatalog, onTabChange, refreshCallback); + } else { + // Only update badge if dropping on inactive tab + updateTabCounterInPlace(nodeRef, tabBtn, tab, currentCatalog); + } + }); + } + } + } + } catch (err) { console.error("[Neurad] Card drop error:", err); } + } + }; + } else { + tabBtn.draggable = false; + } + + tabBar.appendChild(tabBtn); + }); + + // --- NEW TAB BUTTON --- + const newTabBtn = document.createElement("div"); + newTabBtn.innerText = "+"; + newTabBtn.style.cssText = ` + display: flex; justify-content: center; align-items: center; + height: 28px; width: 28px; font-weight: 100; font-size: 24px; + line-height: 1; color: ${CONFIG.COLORS.text_main}; + background: transparent; border: none; border-radius: 4px; + margin-left: 10px; margin-top: 4px; cursor: pointer; flex-shrink: 0; + `; + newTabBtn.onmouseover = function() { this.style.backgroundColor = CONFIG.COLORS.bg_card_active; }; + newTabBtn.onmouseout = function() { this.style.backgroundColor = "transparent"; }; + newTabBtn.onclick = () => { + const name = prompt("New tab name:", "New Tab"); + if (name) { createNewTab(nodeRef, name); if (onTabChange) onTabChange(); } + }; + tabBar.appendChild(newTabBtn); + + container.appendChild(tabBar); + + // Restore scroll position if needed + if (scrollPosition > 0) { + setTimeout(() => { tabBar.scrollTop = scrollPosition; }, 10); + } +} + +// ============================================================================ +// UI RENDERING: LORA GRID +// ============================================================================ +/** + * Renders the grid of LoRA cards based on the filtered list. + * + * Features: + * - Virtual-like caching for images (NeuradImageCache). + * - Dynamic metadata merging (Server Meta + Local Cache). + * - Interactive controls: Strength slider, Info modal, Remove button. + * - Drag & Drop: Reordering within grid, moving between tabs. + * - Scroll lock during drag operations to prevent layout shifts. + * + * @param {Object} nodeRef - The node instance. + * @param {Array} fullCatalog - The full LoRA catalog. + * @param {HTMLElement} container - Parent DOM element. + * @param {Function} refreshCallback - Callback to refresh the UI. + * @param {number} scrollPosition - Vertical scroll offset to restore. + */ +function renderLoraGrid(nodeRef, fullCatalog, container, refreshCallback, scrollPosition = 0) { + const currentTabId = nodeRef.properties.tabsConfig.activeTabId; + const filteredLoras = getLorasForTab(fullCatalog, nodeRef, currentTabId); + const currentTab = nodeRef.properties.tabsConfig.tabs.find(t => t.id === currentTabId); + const isLibrary = (currentTabId === "library"); + + const gridContainer = document.createElement("div"); + gridContainer.style.cssText = `display:grid; grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); gap: 15px; padding:15px; overflow-y:auto; flex:1; grid-auto-rows: ${CONFIG.CARD_HEIGHT}; align-content:start; align-items:start; min-height:200px; transition:background 0.2s;`; + + requestAnimationFrame(() => { gridContainer.scrollTop = scrollPosition; }); + + // Scroll Lock Logic: Prevents native scrolling while dragging a card to keep target visible + let gridScrollLockActive = false; + let gridScrollLockValue = 0; + gridContainer.addEventListener("scroll", () => { + if (gridScrollLockActive) gridContainer.scrollTop = gridScrollLockValue; + }); + + // Empty State + if (filteredLoras.length === 0) { + const emptyMsg = document.createElement("div"); + emptyMsg.style.cssText = `grid-column:1/-1; text-align:center; padding:40px; color:${CONFIG.COLORS.text_faded}; font-size:14px; white-space:pre-line;`; + emptyMsg.innerText = isLibrary ? "No LoRAs found in your folders." : "This tab is empty.\nDrag LoRAs from the Library."; + gridContainer.appendChild(emptyMsg); + } else { + filteredLoras.forEach((lora, index) => { + const loraKey = lora.relative_path; + + // --- 1. DATA & CACHE MERGING --- + const cachedMeta = (typeof LocalCache !== 'undefined') ? LocalCache.get(loraKey) : null; + const isEdited = !!cachedMeta; + + // Determine if the LoRA has valid info (Server Meta OR Manual Edits) + let has_info = false; + if (lora.has_meta && lora.meta && Object.keys(lora.meta).length > 0) { + has_info = true; + } else if (cachedMeta) { + // Check if manual edits exist (URL, Cover, Name, Words, Strength) + if (cachedMeta.customImageUrl || cachedMeta.coverImage || + (cachedMeta.name && cachedMeta.name !== lora.display_name) || + (cachedMeta.trainedWords && cachedMeta.trainedWords.length > 0) || + (cachedMeta.strengthMin !== null && cachedMeta.strengthMin !== undefined) || + (cachedMeta.strengthMax !== null && cachedMeta.strengthMax !== undefined)) { + has_info = true; + } + } + + // Force 'has_meta' flag if manual info exists (for filtering consistency) + if (has_info) { + lora.has_meta = true; + if (!lora.meta) lora.meta = {}; + if (cachedMeta) lora.meta = { ...lora.meta, ...cachedMeta }; + } + + // --- 2. IMAGE SELECTION PRIORITY --- + let imageUrl = null; + if (cachedMeta && cachedMeta.customImageUrl) { + imageUrl = cachedMeta.customImageUrl; // Priority 1: Custom URL + } else if (cachedMeta && cachedMeta.coverImage) { + imageUrl = cachedMeta.coverImage; // Priority 2: Selected Cover + } else if (cachedMeta && cachedMeta.images && cachedMeta.images.length > 0) { + imageUrl = cachedMeta.images[0].url; // Priority 3: Cached First Image + } else if (lora.has_meta && lora.meta && lora.meta.images && lora.meta.images.length > 0) { + imageUrl = lora.meta.images[0].url; // Priority 4: Server First Image + } + const hasImages = !!imageUrl; + + // --- 3. DISPLAY NAME --- + let displayName = lora.display_name || "Unknown"; + if (cachedMeta && cachedMeta.name) displayName = cachedMeta.name; + else if (lora.has_meta && lora.meta && lora.meta.name) displayName = lora.meta.name; + if (displayName.length > 30) displayName = displayName.substring(0, 27) + "..."; + + // --- 4. ACTIVE STATE (ON/OFF) --- + const currentMap = nodeRef.properties.activeLorasMap || {}; + const storedState = currentMap[loraKey] || { on: false, strength: 1.0 }; + const isOn = storedState.on; + + // --- 5. CARD CREATION --- + const card = document.createElement("div"); + card.style.cssText = `background:${isOn ? CONFIG.COLORS.bg_card_active : CONFIG.COLORS.bg_card_inactive}; border:1px solid ${isOn ? CONFIG.COLORS.bg_card_active : CONFIG.COLORS.border_default}; border-radius:6px; padding:6px; display:flex; flex-direction:column; align-items:center; cursor:grab; transition:background 0.2s, transform 0.1s; gap:6px; height:${CONFIG.CARD_HEIGHT_PX}; overflow:hidden; position:relative;`; + card.draggable = true; + + card.ondragend = () => { + neuradDragState = null; + card.style.opacity = "1"; + }; + + // Hover Effects + card.onmouseenter = function() { + const currentState = (nodeRef.properties.activeLorasMap[loraKey] || {}).on || false; + this.style.backgroundColor = currentState ? CONFIG.COLORS.bg_card_active_hover : CONFIG.COLORS.bg_card_hover; + this.style.transform = "scale(1.03)"; + this.style.zIndex = "10"; + this.style.boxShadow = "0 4px 15px rgba(0,0,0,0.5)"; + if (removeBtn) removeBtn.style.opacity = "1"; + }; + card.onmouseleave = function() { + const currentState = (nodeRef.properties.activeLorasMap[loraKey] || {}).on || false; + this.style.backgroundColor = currentState ? CONFIG.COLORS.bg_card_active : CONFIG.COLORS.bg_card_inactive; + this.style.transform = "scale(1)"; + this.style.zIndex = "1"; + this.style.boxShadow = "none"; + if (removeBtn) removeBtn.style.opacity = "0"; + }; + + // --- 6. IMAGE CONTAINER --- + const imgContainer = document.createElement("div"); + imgContainer.style.cssText = `width:100%; height:160px; background: transparent; border-radius:4px; overflow:hidden; display:flex; justify-content:center; align-items:center; font-size:30px; color:${CONFIG.COLORS.text_faded}; margin-bottom:4px; position:relative;`; + + if (hasImages) { + // Retrieve from cache or create new + let img = NeuradImageCache.get(imageUrl); + if (img) { + img = img.cloneNode(true); + } else { + img = document.createElement("img"); + img.src = imageUrl; + img.onload = () => { NeuradImageCache.set(imageUrl, img); }; + img.onerror = function() { + this.style.display = "none"; + imgContainer.innerText = "⚠️ Err"; + imgContainer.style.color = CONFIG.COLORS.text_error; + imgContainer.style.fontSize = "10px"; + imgContainer.style.cursor = "pointer"; + imgContainer.onclick = (e) => { e.stopPropagation(); alert("Invalid image. Edit LoRA to change URL."); }; + }; + } + img.style.cssText = "width:100%; height:100%; object-fit:cover;"; + imgContainer.appendChild(img); + if (imgContainer.innerText === "") imgContainer.style.pointerEvents = "none"; + } else { + // Placeholder for "Get Info" + imgContainer.style.background = CONFIG.COLORS.bg_dark; + imgContainer.style.cursor = "pointer"; + imgContainer.style.flexDirection = "column"; + imgContainer.style.gap = "8px"; + imgContainer.style.pointerEvents = "auto"; + + const icon = document.createElement("div"); icon.innerText = "📥"; icon.style.fontSize = "32px"; icon.style.color = CONFIG.COLORS.bg_card_active; + const label = document.createElement("div"); label.innerText = "Get Info"; label.style.fontSize = "11px"; label.style.color = CONFIG.COLORS.text_main; label.style.fontWeight = "bold"; + imgContainer.appendChild(icon); imgContainer.appendChild(label); + imgContainer.onmouseenter = function() { this.style.background = CONFIG.COLORS.bg_tab_hover; }; + imgContainer.onmouseleave = function() { this.style.background = CONFIG.COLORS.bg_dark; }; + imgContainer.onclick = (e) => { e.stopPropagation(); e.preventDefault(); triggerFetchSingleInfo(nodeRef, lora, imgContainer, refreshCallback); }; + } + + // --- 7. NAME LABEL --- + const nameSpan = document.createElement("div"); + nameSpan.innerText = displayName; + nameSpan.style.cssText = `font-size:11px; text-align:center; width:100%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:${CONFIG.COLORS.text_main}; pointer-events:none; margin-bottom:4px;`; + nameSpan.title = (cachedMeta && cachedMeta.name) ? cachedMeta.name : ((lora.meta && lora.meta.name) ? lora.meta.name : lora.display_name); + + // --- 8. CONTROLS (Strength + Info) --- + const controlsRow = document.createElement("div"); + controlsRow.style.cssText = `display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; width:100%; position: relative; overflow: visible;`; + + const topRow = document.createElement("div"); + topRow.style.cssText = "display:flex; align-items:center; justify-content:center; gap:6px; width:100%;"; + + // Strength Widget + const strengthWidget = document.createElement("div"); + strengthWidget.style.cssText = `display: flex; align-items: center; justify-content: space-between; width: 70px; height: 18px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; border-radius: 20px; padding: 0 2px; box-sizing: border-box; cursor: text; user-select: none; font-family: sans-serif;`; + + const btnMinus = document.createElement("div"); btnMinus.innerText = "◄"; btnMinus.style.cssText = `color: ${CONFIG.COLORS.bg_button}; font-size: 12px; font-weight: bold; cursor: pointer; user-select: none; flex-shrink: 0; line-height: 1; padding: 0 0px; transition: color 0.2s;`; + btnMinus.onmouseover = () => { btnMinus.style.color = CONFIG.COLORS.bg_button_hover; }; btnMinus.onmouseout = () => { btnMinus.style.color = CONFIG.COLORS.bg_button; }; + + const valueDisplay = document.createElement("div"); valueDisplay.innerText = storedState.strength.toFixed(2); valueDisplay.style.cssText = `flex: 1; text-align: center; font-size: 11px; color: ${CONFIG.COLORS.text_main}; cursor: text; user-select: text; padding: 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 24px;`; + valueDisplay.contentEditable = "true"; + valueDisplay.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") valueDisplay.blur(); }; + valueDisplay.oninput = (e) => e.stopPropagation(); + valueDisplay.onblur = () => { let val = parseFloat(valueDisplay.innerText); if (isNaN(val)) val = 0; val = Math.max(-99.95, Math.min(99.95, val)); val = Math.round(val * 20) / 20; valueDisplay.innerText = val.toFixed(2); updateStrength(val); }; + + const btnPlus = document.createElement("div"); btnPlus.innerText = "►"; btnPlus.style.cssText = `color: ${CONFIG.COLORS.bg_button}; font-size: 12px; font-weight: bold; cursor: pointer; user-select: none; flex-shrink: 0; line-height: 1; padding: 0 0px; transition: color 0.2s;`; + btnPlus.onmouseover = () => { btnPlus.style.color = CONFIG.COLORS.bg_button_hover; }; btnPlus.onmouseout = () => { btnPlus.style.color = CONFIG.COLORS.bg_button; }; + + const updateStrength = (newVal) => { nodeRef.properties.activeLorasMap[loraKey] = { on: isOn, strength: newVal, name: lora.name, subfolder: lora.subfolder }; syncLoraDataToWidget(nodeRef); }; + const step = 0.05; + btnMinus.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let current = parseFloat(valueDisplay.innerText) || 0; let newVal = Math.round((current - step) * 20) / 20; newVal = Math.max(-99.95, Math.min(99.95, newVal)); valueDisplay.innerText = newVal.toFixed(2); updateStrength(newVal); }; + btnPlus.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let current = parseFloat(valueDisplay.innerText) || 0; let newVal = Math.round((current + step) * 20) / 20; newVal = Math.max(-99.95, Math.min(99.95, newVal)); valueDisplay.innerText = newVal.toFixed(2); updateStrength(newVal); }; + strengthWidget.onmousedown = (e) => e.stopPropagation(); strengthWidget.onclick = (e) => e.stopPropagation(); + strengthWidget.appendChild(btnMinus); strengthWidget.appendChild(valueDisplay); strengthWidget.appendChild(btnPlus); + + // Info Button + const infoBtn = document.createElement("div"); + infoBtn.innerText = "ℹ️"; + infoBtn.style.cssText = `position: absolute; right: 0px; top: -3px; font-size: 16px; cursor: pointer; padding: 2px; border-radius: 4px; color: #aaa; display: flex; align-items: center; justify-content: center; flex-shrink: 0; z-index: 5; filter: none; transform: translateY(-1px);`; + if (!has_info) infoBtn.style.filter = "grayscale(100%)"; + + // Prepare data for modal (Cache > Meta > Empty) + let dataForModal = {}; + if (cachedMeta) dataForModal = cachedMeta; + else if (lora.has_meta && lora.meta) dataForModal = lora.meta; + else dataForModal = { name: lora.display_name }; + + infoBtn.onclick = (e) => { e.stopPropagation(); openLoraInfoModal(dataForModal, displayName, loraKey, nodeRef); }; + + topRow.appendChild(strengthWidget); + controlsRow.appendChild(infoBtn); + controlsRow.appendChild(topRow); + + // Recommended Strength Label + let sMin = null, sMax = null; + if (cachedMeta) { sMin = cachedMeta.strengthMin; sMax = cachedMeta.strengthMax; } + else if (lora.has_meta && lora.meta) { sMin = lora.meta.strengthMin; sMax = lora.meta.strengthMax; } + + const hasMin = (sMin !== null && sMin !== undefined); + const hasMax = (sMax !== null && sMax !== undefined); + let recText = null; + + if (hasMin && hasMax) { + const vMin = parseFloat(sMin).toFixed(2); const vMax = parseFloat(sMax).toFixed(2); + recText = (vMin === vMax) ? `R.S. : ${vMin}` : `R.S. : ${vMin} - ${vMax}`; + } else if (hasMin) { recText = `R.S. : ${parseFloat(sMin).toFixed(2)}`; } + else if (hasMax) { recText = `R.S. : ${parseFloat(sMax).toFixed(2)}`; } + + const recLabel = document.createElement("div"); + recLabel.innerText = recText || ""; + recLabel.style.cssText = `font-size: 9px; color: ${CONFIG.COLORS.text_main}; text-align: center; white-space: nowrap; margin-top: 5px; font-weight: 700; letter-spacing: 0.5px; line-height: 1.2; min-height: 11px;`; + controlsRow.appendChild(recLabel); + + card.append(imgContainer, nameSpan, controlsRow); + + // Remove Button (Only in custom tabs) + let removeBtn = null; + if (!isLibrary) { + removeBtn = document.createElement("div"); + removeBtn.innerText = "❌"; + removeBtn.style.cssText = "font-size:16px; cursor:pointer; display:flex; align-items:center; justify-content:center; position:absolute; top:4px; right:4px; opacity:0; transition:opacity 0.2s; pointer-events:auto;"; + removeBtn.onclick = (e) => { e.stopPropagation(); if (confirm("Remove this LoRA from this tab?")) { removeLoraFromTab(nodeRef, currentTabId, loraKey); if (refreshCallback) refreshCallback(); } }; + removeBtn.onmouseenter = (e) => { e.stopPropagation(); removeBtn.style.opacity = "1"; }; + card.appendChild(removeBtn); + } + + // Card Click (Toggle On/Off) + card.onclick = (e) => { + if (e.target === strengthWidget || e.target === infoBtn || (removeBtn && e.target === removeBtn)) return; + const currentMap = nodeRef.properties.activeLorasMap || {}; + const currentState = currentMap[loraKey] || { on: false, strength: 1.0 }; + const newState = !currentState.on; + nodeRef.properties.activeLorasMap[loraKey] = { on: newState, strength: currentState.strength, name: lora.name, subfolder: lora.subfolder }; + syncLoraDataToWidget(nodeRef); + if (refreshCallback) refreshCallback(); + }; + + // --- DRAG & DROP INTERNAL LOGIC --- + + // 1. DRAG START + card.ondragstart = (e) => { + neuradDragState = "lora-card"; + e.dataTransfer.setData("application/json", JSON.stringify({ + loraPath: loraKey, sourceTabId: currentTabId, index: index, dragType: "lora-card" + })); + e.dataTransfer.effectAllowed = "copyMove"; + card.style.opacity = "0.5"; + // Lock grid scroll + gridScrollLockValue = gridContainer.scrollTop; + gridScrollLockActive = true; + }; + + // 2. DRAG OVER (Visual Feedback) + card.ondragover = (e) => { + e.preventDefault(); e.stopPropagation(); + card.style.border = "1px solid #666"; // Reset + if (neuradDragState === "lora-card") { + const rect = card.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + if (e.clientX < midX) { + card.style.borderLeft = "3px solid #4da6ff"; + } else { + card.style.borderRight = "3px solid #4da6ff"; + } + } + }; + + // 3. DRAG LEAVE + card.ondragleave = (e) => { + if (e.target === card) card.style.border = "1px solid #666"; + }; + + // 4. DROP (Reorder or Add) + card.ondrop = (e) => { + e.preventDefault(); e.stopPropagation(); + card.style.border = "1px solid #666"; + const data = e.dataTransfer.getData("application/json"); + if (!data) return; + + try { + const payload = JSON.parse(data); + const targetPath = lora.relative_path; + + if (payload.sourceTabId === currentTabId) { + // Reorder within same tab + const currentList = currentTab.loras; + let oldIndex = currentList.findIndex(p => p.replace(/\\/g, '/') === payload.loraPath.replace(/\\/g, '/')); + let targetIndex = currentList.findIndex(p => p.replace(/\\/g, '/') === targetPath.replace(/\\/g, '/')); + + if (oldIndex !== -1 && targetIndex !== -1 && oldIndex !== targetIndex) { + const rect = card.getBoundingClientRect(); + const insertBefore = (e.clientX < rect.left + rect.width / 2); + let finalIndex = insertBefore + ? (oldIndex < targetIndex ? targetIndex - 1 : targetIndex) + : (oldIndex < targetIndex ? targetIndex : targetIndex + 1); + + finalIndex = Math.max(0, Math.min(finalIndex, currentList.length)); + const item = currentList.splice(oldIndex, 1)[0]; + currentList.splice(finalIndex, 0, item); + saveTabsConfig(nodeRef); + if (refreshCallback) setTimeout(() => refreshCallback(), 50); + } + } else { + // Drop from different tab (Add) + if (currentTabId !== "library" && !currentTab.locked && !currentTab.loras.includes(payload.loraPath)) { + const rect = card.getBoundingClientRect(); + const visualIndex = filteredLoras.indexOf(lora); + const insertBefore = (e.clientX < rect.left + rect.width / 2); + let targetIndex = insertBefore ? visualIndex : visualIndex + 1; + targetIndex = Math.max(0, Math.min(targetIndex, currentTab.loras.length)); + + currentTab.loras.splice(targetIndex, 0, payload.loraPath); + saveTabsConfig(nodeRef); + if (refreshCallback) setTimeout(() => refreshCallback(), 50); + } + } + } catch (err) { console.error("[Neurad] Card drop error:", err); } + }; + + // 5. DRAG END (Cleanup) + card.ondragend = () => { + neuradDragState = null; + card.style.opacity = "1"; + card.style.border = "1px solid #666"; + gridScrollLockActive = false; // Unlock scroll + }; + + gridContainer.appendChild(card); + }); + } + container.appendChild(gridContainer); + if (scrollPosition > 0) setTimeout(() => { gridContainer.scrollTop = scrollPosition; }, 10); +} + +// ============================================================================ +// MODALS & POPUPS +// ============================================================================ + +/** + * Creates a generic modal popup overlay. + * Used as the base for both the Edit and Info modals. + * + * @param {HTMLElement} contentElement - The DOM element to display inside the modal. + * @param {string} title - The title displayed in the header. + * @param {Function} onCloseCallback - Optional callback executed when the modal closes. + * @param {string} width - CSS width value (e.g., "800px"). + * @param {string} height - CSS height value (e.g., "auto"). + * @returns {HTMLElement} The overlay element. + */ +function createPopup(contentElement, title, onCloseCallback = null, width = "800px", height = "auto") { + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:2000; display:flex; justify-content:center; align-items:center; cursor:default;"; + + const modal = document.createElement("div"); + modal.style.cssText = `background:${CONFIG.COLORS.bg_panel}; width:${width}; height:${height}; min-height:400px; max-width:98%; max-height:95vh; border-radius:8px; border:0px; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 0 20px rgba(0,0,0,0.5); color:#eee; font-family:sans-serif; pointer-events:auto; position:relative;`; + + const header = document.createElement("div"); + header.style.cssText = `position: relative; padding: 0; background:${CONFIG.COLORS.bg_header}; border-bottom:1px solid ${CONFIG.COLORS.separator_header}; display:flex; align-items:center; flex-shrink:0; height: 50px;`; + + const h3 = document.createElement("h3"); + h3.innerText = title; + h3.style.cssText = `color: ${CONFIG.COLORS.text_node_title}; margin:0; padding: 0 15px; font-size:20px; font-weight: 300; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;`; + + const closeBtn = document.createElement("button"); + closeBtn.innerText = "✕"; + closeBtn.style.cssText = ` + font-size:20px; color: ${CONFIG.COLORS.text_node_title}; + position: absolute; right: 0; top: 0; bottom: 0; + width: 45px; background: transparent; border: none; + cursor: pointer; padding: 0; margin: 0; + display: flex; align-items: center; justify-content: center; + z-index: 10; + `; + + const bgSpan = document.createElement("span"); + bgSpan.style.cssText = ` + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + background: transparent; z-index: -1; transition: background 0.2s; + `; + closeBtn.appendChild(bgSpan); + + closeBtn.onmouseover = function() { bgSpan.style.background = CONFIG.COLORS.bg_close_hover; }; + closeBtn.onmouseout = function() { bgSpan.style.background = 'transparent'; }; + + header.append(h3, closeBtn); + + contentElement.style.cssText = `flex: 1; overflow-y: auto; overflow-x: hidden; display: block; padding-top: 0px; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; scrollbar-width: thin; scrollbar-color:${CONFIG.COLORS.bg_button} ${CONFIG.COLORS.bg_dark}; width: 100%; box-sizing: border-box;`; + + modal.append(header, contentElement); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const closePopup = () => { + if (onCloseCallback) onCloseCallback(); + document.removeEventListener('mousedown', handleOutsideClick); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + + closeBtn.onclick = closePopup; + + const handleOutsideClick = (e) => { if (overlay === e.target) closePopup(); }; + setTimeout(() => document.addEventListener('mousedown', handleOutsideClick), 10); + + return overlay; +} + +// ============================================================================ +// EDIT MODAL +// ============================================================================ +/** + * Opens the modal for editing LoRA metadata. + * + * Logic: + * 1. Fetches the freshest data available (Local Cache > Server Catalog > Passed Meta). + * 2. Renders form fields: Name, Trigger Words, Cover Image, Strength Range. + * 3. Handles "Get CivitAI Data" fetch. + * 4. Saves changes to Local Cache and Server, then updates the UI. + * + * @param {Object} meta - Initial metadata object (fallback). + * @param {string} title - Default title. + * @param {string} loraPath - Unique identifier for the LoRA. + * @param {Object} nodeRef - The node instance. + * @param {Function} onSaveCallback - Callback executed after successful save. + */ +function openLoraEditModal(meta, title, loraPath, nodeRef, onSaveCallback) { + const contentBody = document.createElement("div"); + contentBody.style.cssText = `width: 100%; height: 100%; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; padding: 20px; box-sizing: border-box; font-family: sans-serif; color: ${CONFIG.COLORS.text_main};`; + + // 1. FETCH FRESHEST DATA + // Priority: Local Cache > Node Catalog > Passed Meta + let freshData = null; + if (typeof LocalCache !== 'undefined' && loraPath) { + freshData = LocalCache.get(loraPath); + } + if (!freshData && nodeRef && nodeRef.properties._fullLoraCatalog) { + const loraObj = nodeRef.properties._fullLoraCatalog.find(l => l.relative_path === loraPath); + if (loraObj && loraObj.meta) freshData = loraObj.meta; + } + if (!freshData) { + freshData = (meta && typeof meta === 'object') ? meta : { name: title }; + } + + // Create editable copy + let editData = { + name: freshData.name || title || "", + trainedWords: freshData.trainedWords ? [...freshData.trainedWords] : [], + strengthMin: freshData.strengthMin, + strengthMax: freshData.strengthMax, + comment: freshData.comment || "", + images: freshData.images ? [...freshData.images] : [], + civitaiUrl: freshData.civitaiUrl, + coverImage: freshData.coverImage || null, + customImageUrl: freshData.customImageUrl || null + }; + + const formContainer = document.createElement("div"); + formContainer.style.cssText = "padding: 20px; display: flex; flex-direction: column; gap: 15px;"; + contentBody.appendChild(formContainer); + + // 2. FORM RENDERER + const renderForm = () => { + formContainer.innerHTML = ""; + + // Header + const headerDiv = document.createElement("div"); + headerDiv.innerHTML = `Edit Mode`; + formContainer.appendChild(headerDiv); + + // Field 1: Display Name + const nameGroup = document.createElement("div"); + nameGroup.innerHTML = "Displayed Name"; + const nameInput = document.createElement("input"); + nameInput.type = "text"; nameInput.value = editData.name; + nameInput.style.cssText = `width: 100%; padding: 8px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; color: ${CONFIG.COLORS.text_main}; border-radius: 8px; margin-top: 5px;`; + nameInput.onchange = (e) => { editData.name = e.target.value; }; + nameGroup.appendChild(nameInput); + formContainer.appendChild(nameGroup); + + // Field 2: Trigger Words + const tagsGroup = document.createElement("div"); + tagsGroup.innerHTML = `Trigger Words
Use semicolon as separator (;).
`; + const tagsInput = document.createElement("textarea"); + tagsInput.value = editData.trainedWords.join("; "); + tagsInput.style.cssText = `width: 100%; min-height: 100px; padding: 8px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; color: ${CONFIG.COLORS.text_main}; border-radius: 8px; margin-top: 5px; font-family: monospace; resize: none; overflow-y: hidden; box-sizing: border-box;`; + + const autoResize = () => { tagsInput.style.height = 'auto'; tagsInput.style.height = tagsInput.scrollHeight + 'px'; }; + tagsInput.addEventListener('input', autoResize); + setTimeout(autoResize, 0); + tagsInput.onchange = (e) => { editData.trainedWords = e.target.value.split(';').map(w => w.trim()).filter(w => w !== ""); }; + tagsGroup.appendChild(tagsInput); + formContainer.appendChild(tagsGroup); + + // Field 3: Cover Image + const imageSection = document.createElement("div"); + imageSection.innerHTML = `Cover Image
Custom URL :
`; + const urlGroup = document.createElement("div"); + const customUrlInput = document.createElement("input"); + customUrlInput.type = "text"; customUrlInput.placeholder = "https://..."; + customUrlInput.value = editData.customImageUrl || ""; + customUrlInput.style.cssText = `width: 100%; padding: 6px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; color: ${CONFIG.COLORS.text_main}; border-radius: 8px; font-size: 11px;`; + customUrlInput.onchange = (e) => { + const val = e.target.value.trim(); + editData.customImageUrl = (val === "") ? null : val; + if (val) editData.coverImage = val; + }; + urlGroup.appendChild(customUrlInput); + imageSection.appendChild(urlGroup); + + // Image Grid Selector + if (editData.images && editData.images.length > 0) { + urlGroup.style.cssText = "margin-bottom: 10px;"; + const gridDiv = document.createElement("div"); + gridDiv.style.cssText = `display: grid; grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); gap: 5px; max-height: 150px; overflow-y: auto; padding: 5px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; border-radius: 8px;`; + const currentCoverUrl = editData.coverImage || (editData.customImageUrl ? editData.customImageUrl : (editData.images[0].url)); + + editData.images.forEach((img, idx) => { + const thumb = document.createElement("div"); + const isSelected = (img.url === currentCoverUrl); + thumb.style.cssText = `aspect-ratio: 1; background: #000; border: ${isSelected ? `2px solid ${CONFIG.COLORS.border_bright_blue}` : `1px solid ${CONFIG.COLORS.border_default}`}; border-radius: 3px; overflow: hidden; cursor: pointer; opacity: ${isSelected ? '1' : '0.7'};`; + const imgEl = document.createElement("img"); + imgEl.src = img.url; + imgEl.style.cssText = "width: 100%; height: 100%; object-fit: cover;"; + thumb.appendChild(imgEl); + thumb.onclick = () => { + editData.coverImage = img.url; + editData.customImageUrl = null; + customUrlInput.value = ""; + renderForm(); + }; + gridDiv.appendChild(thumb); + }); + imageSection.appendChild(gridDiv); + const helpText = document.createElement("div"); + helpText.innerText = "Click on thumbnail to set as cover"; + helpText.style.cssText = `font-size: 10px; color: ${CONFIG.COLORS.text_faded}; margin-top: 5px; text-align: center;`; + imageSection.appendChild(helpText); + } + formContainer.appendChild(imageSection); + + // Field 4: Recommended Strength + const strengthGroup = document.createElement("div"); + strengthGroup.innerHTML = `Recommended Strength
Leave empty to hide
`; + const strengthRow = document.createElement("div"); + strengthRow.style.cssText = "display: flex; gap: 10px; margin-top: 5px;"; + + const inputMin = document.createElement("input"); + inputMin.type = "number"; inputMin.step = "0.05"; inputMin.placeholder = "Min"; + inputMin.value = (editData.strengthMin !== null && editData.strengthMin !== undefined) ? editData.strengthMin : ""; + inputMin.style.cssText = `flex: 1; padding: 8px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; color: ${CONFIG.COLORS.text_main}; border-radius: 8px;`; + inputMin.onchange = (e) => { editData.strengthMin = (e.target.value === "") ? null : parseFloat(e.target.value); }; + + const inputMax = document.createElement("input"); + inputMax.type = "number"; inputMax.step = "0.05"; inputMax.placeholder = "Max"; + inputMax.value = (editData.strengthMax !== null && editData.strengthMax !== undefined) ? editData.strengthMax : ""; + inputMax.style.cssText = `flex: 1; padding: 8px; background: ${CONFIG.COLORS.bg_dark}; border: 1px solid ${CONFIG.COLORS.border_default}; color: ${CONFIG.COLORS.text_main}; border-radius: 8px;`; + inputMax.onchange = (e) => { editData.strengthMax = (e.target.value === "") ? null : parseFloat(e.target.value); }; + + strengthRow.appendChild(inputMin); strengthRow.appendChild(inputMax); + strengthGroup.appendChild(strengthRow); + formContainer.appendChild(strengthGroup); + + // 3. ACTION BUTTONS + const actionsRow = document.createElement("div"); + actionsRow.style.cssText = `display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 15px; gap: 10px;`; + + // Fetch CivitAI Button + const fetchBtn = document.createElement("button"); + fetchBtn.innerText = "📥 Get CivitAI Data"; + fetchBtn.style.cssText = `background: ${CONFIG.COLORS.accent_blue_select}; color: ${CONFIG.COLORS.text_main}; padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 12px; white-space: nowrap;`; + fetchBtn.onmouseover = () => { if(!fetchBtn.disabled) fetchBtn.style.background = CONFIG.COLORS.accent_blue_faded; }; + fetchBtn.onmouseout = () => { if(!fetchBtn.disabled) fetchBtn.style.background = CONFIG.COLORS.accent_blue_select; }; + + fetchBtn.onclick = async () => { + if (!loraPath) { alert("Error: Path missing"); return; } + if (!confirm("Download will erase manual changes. Continue?")) return; + + fetchBtn.disabled = true; fetchBtn.innerText = "⏳..."; + try { + const response = await fetch('/neurad/fetch-single-info', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: loraPath }) + }); + const result = await response.json(); + if (result.success && result.meta) { + editData.name = result.meta.name || editData.name; + editData.trainedWords = result.meta.trainedWords || editData.trainedWords; + editData.strengthMin = result.meta.strengthMin; + editData.strengthMax = result.meta.strengthMax; + editData.civitaiUrl = result.meta.civitaiUrl; + if (result.meta.images && result.meta.images.length > 0) { + editData.images = result.meta.images; + if (!editData.coverImage && !editData.customImageUrl) editData.coverImage = result.meta.images[0].url; + } + renderForm(); + } else { alert("Failure: " + (result.error || "No data")); } + } catch (err) { alert("Error: " + err.message); } + finally { fetchBtn.disabled = false; fetchBtn.innerText = "📥 Get CivitAI Data"; } + }; + + // Right Group (Cancel + Save) + const rightGroup = document.createElement("div"); + rightGroup.style.cssText = "display: flex; gap: 10px;"; + + const cancelBtn = document.createElement("button"); + cancelBtn.innerText = "Cancel"; + cancelBtn.style.cssText = `padding: 8px 16px; background: ${CONFIG.COLORS.bg_button}; color: ${CONFIG.COLORS.text_main}; border: none; border-radius: 8px; cursor: pointer; font-weight: bold;`; + cancelBtn.onmouseover = () => { if(!cancelBtn.disabled) cancelBtn.style.background = CONFIG.COLORS.bg_button_hover; }; + cancelBtn.onmouseout = () => { if(!cancelBtn.disabled) cancelBtn.style.background = CONFIG.COLORS.bg_button; }; + cancelBtn.onclick = () => { + const overlay = contentBody.parentElement.parentElement; + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + + const saveBtn = document.createElement("button"); + saveBtn.innerText = "💾 Save"; + saveBtn.style.cssText = `background: ${CONFIG.COLORS.accent_blue_select}; color: ${CONFIG.COLORS.text_main}; padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 12px; white-space: nowrap;`; + saveBtn.onmouseover = () => { if(!saveBtn.disabled) saveBtn.style.background = CONFIG.COLORS.accent_blue_faded; }; + saveBtn.onmouseout = () => { if(!saveBtn.disabled) saveBtn.style.background = CONFIG.COLORS.accent_blue_select; }; + + saveBtn.onclick = async () => { + if (!loraPath) { alert("Error: File path not found."); return; } + saveBtn.disabled = true; saveBtn.innerText = "Saving..."; + + try { + const response = await fetch('/neurad/update-lora-info', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: loraPath, meta: editData }) + }); + if (!response.ok) throw new Error("Server error: " + response.status); + const result = await response.json(); + + if (result.success) { + // Update Local Cache + if (typeof LocalCache !== 'undefined') LocalCache.set(loraPath, editData); + + // Update Node Catalog in memory + const catalog = nodeRef.properties._fullLoraCatalog || []; + const loraObj = catalog.find(l => l.relative_path === loraPath); + if (loraObj) { + loraObj.has_meta = true; + if (!loraObj.meta) loraObj.meta = {}; + loraObj.meta.name = editData.name; + loraObj.meta.strengthMin = editData.strengthMin; + loraObj.meta.strengthMax = editData.strengthMax; + loraObj.meta.trainedWords = editData.trainedWords; + loraObj.display_name = editData.name || loraObj.display_name; + } + + syncLoraDataToWidget(nodeRef); + if (window.neuradRefreshCallback) window.neuradRefreshCallback(); + if (onSaveCallback) onSaveCallback(editData); + + const overlay = contentBody.parentElement.parentElement; + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + + } else { throw new Error(result.error || "Failure"); } + } catch (err) { + alert("Save error: " + err.message); + saveBtn.disabled = false; saveBtn.innerText = "💾 Save"; + } + }; + + rightGroup.appendChild(cancelBtn); + rightGroup.appendChild(saveBtn); + actionsRow.appendChild(fetchBtn); + actionsRow.appendChild(rightGroup); + formContainer.appendChild(actionsRow); + }; + + renderForm(); + createPopup(contentBody, "Edit: " + (editData.name || "LoRA"), null, "min(1300px, 80vw)", "auto"); +} + +// ============================================================================ +// INFO MODAL (READ-ONLY) +// ============================================================================ +/** + * Opens the read-only modal for viewing LoRA details. + * + * Features: + * - Displays Trigger Words (clickable to copy to clipboard). + * - Displays Images (clickable to zoom, shows metadata if available). + * - Displays Recommended Strength. + * - Provides link to CivitAI. + * - Includes an "Edit" button to switch to edit mode. + * + * @param {Object} meta - Metadata object. + * @param {string} title - Display title. + * @param {string} loraPath - Unique identifier. + * @param {Object} nodeRef - The node instance. + */ +function openLoraInfoModal(meta, title, loraPath = null, nodeRef) { + const contentBody = document.createElement("div"); + contentBody.style.cssText = `width: 100%; height: 100%; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; padding: 20px; box-sizing: border-box; font-family: sans-serif; color: ${CONFIG.COLORS.text_main};`; + + // Internal renderer to allow dynamic updates (e.g., after editing) + const renderViewContent = (dataToDisplay) => { + contentBody.innerHTML = ""; + const formContainer = document.createElement("div"); + formContainer.style.cssText = "padding: 20px; display: flex; flex-direction: column; gap: 15px;"; + contentBody.appendChild(formContainer); + + // Header with File Path and Edit Button + if (loraPath) { + const editHeader = document.createElement("div"); + editHeader.style.cssText = "display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 5px;"; + + const fileNameLabel = document.createElement("span"); + fileNameLabel.innerText = loraPath; + fileNameLabel.title = loraPath; + fileNameLabel.style.cssText = `font-size: 10px; color: ${CONFIG.COLORS.text_faded}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; transform: translate(-6px, -20px);`; + editHeader.appendChild(fileNameLabel); + + const editBtn = document.createElement("button"); + editBtn.innerText = "✏️ Edit"; + editBtn.style.cssText = `background: ${CONFIG.COLORS.accent_blue_select}; color: ${CONFIG.COLORS.text_main}; padding: 6px 12px; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 11px; white-space: nowrap; flex-shrink: 0;`; + editBtn.onmouseover = () => { if(!editBtn.disabled) editBtn.style.background = CONFIG.COLORS.accent_blue_faded; }; + editBtn.onmouseout = () => { if(!editBtn.disabled) editBtn.style.background = CONFIG.COLORS.accent_blue_select; }; + + editBtn.onclick = () => { + openLoraEditModal(dataToDisplay, dataToDisplay.name, loraPath, nodeRef, (savedData) => { + // Update modal title + const overlay = contentBody.parentElement.parentElement; + if (overlay) { + const popupTitle = overlay.querySelector('h3'); + if (popupTitle) popupTitle.innerText = (savedData.name || "LoRA Details"); + } + // Refresh content + renderViewContent(savedData); + }); + }; + editHeader.appendChild(editBtn); + formContainer.appendChild(editHeader); + } + + // 1. Trigger Words (Interactive Copy) + if (dataToDisplay.trainedWords && dataToDisplay.trainedWords.length > 0) { + const div = document.createElement("div"); + div.innerHTML = "Trigger Words:"; + const tagsDiv = document.createElement("div"); + tagsDiv.style.cssText = "display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px;"; + + const activeWords = new Set(); + const syncClipboard = () => { + const ordered = dataToDisplay.trainedWords.filter(w => activeWords.has(w)); + navigator.clipboard.writeText(ordered.join(", ")); + }; + + dataToDisplay.trainedWords.forEach(word => { + const span = document.createElement("span"); + span.innerText = word; + span.style.cssText = `background: ${CONFIG.COLORS.bg_toggle}; padding: 4px 8px; border-radius: 4px; font-size: 11px; color: ${CONFIG.COLORS.text_main}; border: 1px solid ${CONFIG.COLORS.border_default}; cursor: pointer;`; + span.onmouseover = () => { if (!activeWords.has(word)) span.style.background = CONFIG.COLORS.bg_toggle_faded; }; + span.onmouseout = () => { if (!activeWords.has(word)) span.style.background = CONFIG.COLORS.bg_toggle; }; + span.onclick = () => { + if (activeWords.has(word)) { + activeWords.delete(word); + span.style.background = CONFIG.COLORS.bg_toggle; + span.style.borderColor = CONFIG.COLORS.border_default; + } else { + activeWords.add(word); + span.style.background = CONFIG.COLORS.accent_blue_select; + span.style.borderColor = CONFIG.COLORS.accent_blue_select; + } + syncClipboard(); + }; + tagsDiv.appendChild(span); + }); + div.appendChild(tagsDiv); + formContainer.appendChild(div); + } + + // 2. Images Gallery + if (dataToDisplay.images && dataToDisplay.images.length > 0) { + const sectionDiv = document.createElement("div"); + sectionDiv.innerHTML = "Images:"; + const scrollContainer = document.createElement("div"); + scrollContainer.style.cssText = `display: flex; flex-direction: row; gap: 10px; overflow-x: auto; overflow-y: hidden; padding: 10px 0; width: 100%;margin: 0 auto; scrollbar-width: thin; scrollbar-color:${CONFIG.COLORS.bg_button} ${CONFIG.COLORS.bg_dark}; box-sizing: border-box;`; + + const currentCoverUrl = dataToDisplay.customImageUrl || dataToDisplay.coverImage || (dataToDisplay.images[0] ? dataToDisplay.images[0].url : null); + + dataToDisplay.images.forEach((img, index) => { + const imgWrapper = document.createElement("div"); + imgWrapper.style.cssText = "flex: 0 0 auto; display: flex; flex-direction: column; background: transparent; border-radius: 8px; overflow: hidden; border: 1px solid #333; position: relative; max-width: 100%;"; + + if (currentCoverUrl && img.url === currentCoverUrl) { + const badge = document.createElement("div"); + badge.innerText = "Cover Image"; + badge.style.cssText = `position: absolute; top: 4px; left: 4px; background: ${CONFIG.COLORS.accent_blue_select}; color: white; font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold; z-index: 10;`; + imgWrapper.appendChild(badge); + } + + const imgEl = document.createElement("img"); + imgEl.src = img.url; + imgEl.style.cssText = "display: block; height: 200px; width: auto; object-fit: contain; cursor: pointer;"; + + imgEl.onclick = (e) => { + e.preventDefault(); e.stopPropagation(); + if (img.positive) { + // Image with Metadata + const imgBox = document.createElement("div"); + imgBox.style.cssText = `display: flex; align-items: flex-start; justify-content: center; background: transparent; overflow: visible; width: fit-content;`; + const viewImg = document.createElement("img"); + viewImg.style.cssText = `max-width: max(300px, calc(95vw - 430px)); max-height: 75vh; width: auto; height: auto; object-fit: contain; display: block;`; + imgBox.appendChild(viewImg); + + const textBox = document.createElement("div"); + textBox.style.cssText = `width: 400px; min-width: 400px; background: ${CONFIG.COLORS.bg_dark}; display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; box-sizing: border-box;`; + const txtHeader = document.createElement("div"); + txtHeader.innerText = "Metadata"; + txtHeader.style.cssText = `padding: 15px; background: transparent; color: ${CONFIG.COLORS.text_main}; font-weight: bold; font-size: 14px; flex-shrink: 0;`; + const txtBody = document.createElement("div"); + txtBody.innerText = img.positive; + txtBody.style.cssText = `flex: 1; padding: 15px; overflow-y: auto; background: ${CONFIG.COLORS.bg_dark}; border-top: 1px solid ${CONFIG.COLORS.separator_header}; font-family: monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; scrollbar-width: thin; scrollbar-color:${CONFIG.COLORS.bg_button} ${CONFIG.COLORS.bg_dark};`; + + const copyBtn = document.createElement("button"); + copyBtn.innerText = "📋 Copy Metadata"; + copyBtn.style.cssText = `width: 120px; margin: 10px 15px; align-self: flex-start; background: ${CONFIG.COLORS.accent_blue_select}; color: ${CONFIG.COLORS.text_main}; padding: 6px 12px; border: none; border-radius: 8px; cursor: pointer; font-size: 11px; flex-shrink: 0;`; + copyBtn.onmouseover = () => { copyBtn.style.background = CONFIG.COLORS.accent_blue_faded; }; + copyBtn.onmouseout = () => { copyBtn.style.background = CONFIG.COLORS.accent_blue_select; }; + copyBtn.onclick = function() { + navigator.clipboard.writeText(img.positive); + const originalText = copyBtn.innerText; + copyBtn.innerText = "✅ Copied !"; + copyBtn.style.backgroundColor = CONFIG.COLORS.border_bright_blue; + setTimeout(() => { copyBtn.innerText = originalText; copyBtn.style.backgroundColor = CONFIG.COLORS.accent_blue_select; }, 400); + }; + + textBox.appendChild(txtHeader); textBox.appendChild(txtBody); textBox.appendChild(copyBtn); + + const rowContainer = document.createElement("div"); + rowContainer.style.cssText = `display: flex; flex-direction: row; align-items: flex-start; width: fit-content; max-width: 95vw; height: fit-content; background: #111; overflow: hidden; box-sizing: border-box;`; + rowContainer.appendChild(imgBox); rowContainer.appendChild(textBox); + + // Sync text box height to image height + const syncTextBoxHeight = () => { + const h = viewImg.getBoundingClientRect().height; + if (h > 0) textBox.style.height = h + "px"; + }; + viewImg.onload = () => requestAnimationFrame(syncTextBoxHeight); + viewImg.src = img.url; + + const zoomContent = document.createElement("div"); + zoomContent.style.cssText = "width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;"; + zoomContent.appendChild(rowContainer); + createPopup(zoomContent, "Image Details", null, "fit-content", "fit-content"); + } else { + // Image without Metadata + const zoomContent = document.createElement("div"); + zoomContent.style.cssText = "display: flex; align-items: center; justify-content: center; background: #000; padding: 20px;"; + const viewImg = document.createElement("img"); + viewImg.src = img.url; + viewImg.style.cssText = "max-width: 90vw; max-height: 85vh; width: auto; height: auto; object-fit: contain;"; + zoomContent.appendChild(viewImg); + createPopup(zoomContent, "Image", null, "fit-content", "fit-content"); + } + }; + + if (img.positive) { + const indicator = document.createElement("div"); + indicator.innerText = "📝"; + indicator.style.cssText = "position: absolute; bottom: 4px; right: 4px; background: transparent; color: #fff; font-size: 12px; padding: 2px 5px; border-radius: 3px; pointer-events: none; z-index: 20; opacity: 1;"; + imgWrapper.appendChild(indicator); + } + imgWrapper.appendChild(imgEl); + scrollContainer.appendChild(imgWrapper); + }); + sectionDiv.appendChild(scrollContainer); + formContainer.appendChild(sectionDiv); + } + + // 3. Recommended Strength + const hasMin = (dataToDisplay.strengthMin !== null && dataToDisplay.strengthMin !== undefined); + const hasMax = (dataToDisplay.strengthMax !== null && dataToDisplay.strengthMax !== undefined); + if (hasMin || hasMax) { + let text = ""; + if (hasMin && hasMax) { + const vMin = parseFloat(dataToDisplay.strengthMin).toFixed(2); + const vMax = parseFloat(dataToDisplay.strengthMax).toFixed(2); + text = (vMin === vMax) ? `${vMin}` : `${vMin} - ${vMax}`; + } else if (hasMin) text = parseFloat(dataToDisplay.strengthMin).toFixed(2); + else if (hasMax) text = parseFloat(dataToDisplay.strengthMax).toFixed(2); + + const div = document.createElement("div"); + div.style.cssText = `padding: 10px; background: transparent; color: ${CONFIG.COLORS.text_main};`; + div.innerHTML = `Recommended Strength: ${text}`; + formContainer.appendChild(div); + } + + // 4. CivitAI Link + if (dataToDisplay.civitaiUrl) { + const linkContainer = document.createElement("div"); + linkContainer.style.cssText = `margin-top: 10px; padding-top: 15px; border-top: 1px solid ${CONFIG.COLORS.separator_header};`; + const link = document.createElement("a"); + link.href = dataToDisplay.civitaiUrl; link.target = "_blank"; + link.innerText = "View on CivitAI ↗"; + link.style.cssText = `color: ${CONFIG.COLORS.border_bright_blue}; text-decoration: none; font-weight: bold; font-size: 14px;`; + linkContainer.appendChild(link); + formContainer.appendChild(linkContainer); + } + }; + + // Initialize Data + let initialData = {}; + if (meta && typeof meta === 'object') { + initialData = { ...meta }; + } else { + initialData = { name: title }; + } + if (!initialData.name) initialData.name = title || "Unknown"; + if (!initialData.trainedWords) initialData.trainedWords = []; + if (!initialData.images) initialData.images = []; + + renderViewContent(initialData); + createPopup(contentBody, initialData.name || "LoRA Details", null, "min(1400px, 85vw)", "auto"); +} + +// ============================================================================ +// SIZE & SYNC UTILITIES +// ============================================================================ + +/** + * Enforces a minimum size for the ComfyUI node based on its content. + * Calculates required width based on the longest active LoRA name. + * Calculates required height based on the number of active LoRAs and widget positions. + * + * @param {Object} node - The ComfyUI node instance. + * @param {boolean} immediate - If true, executes immediately; otherwise, debounces the resize. + */ +function enforceMinSize(node, immediate = false) { + if (!node || !node.size) return; + if (immediate && node._resizeTimer) { clearTimeout(node._resizeTimer); node._resizeTimer = null; } + + const executeEnforce = () => { + if (!node || !node.size) return; + const names = node.properties._cachedActiveDisplayNames || []; + const tempCanvas = document.createElement('canvas'); + const ctx = tempCanvas.getContext('2d'); + if (!ctx) return; + ctx.font = "12px sans-serif"; + + // Calculate minimum width based on text length + let maxTextWidth = 0; + const listToCheck = names.length > 0 ? names : ["No active LoRAs"]; + for (let i = 0; i < listToCheck.length; i++) { + const w = ctx.measureText(listToCheck[i]).width; + if (w > maxTextWidth) maxTextWidth = w; + } + const finalMinWidth = Math.max(maxTextWidth + 24, 200); + + // Calculate minimum height based on widget stack and list length + let startY = 40; + if (node.widgets && node.widgets.length > 0) { + const lastWidget = node.widgets[node.widgets.length - 1]; + if (lastWidget && lastWidget.y !== undefined && lastWidget.height !== undefined) { + startY = lastWidget.y + lastWidget.height + 10; + } + } + const lineCount = names.length > 0 ? names.length : 1; + const finalMinHeight = Math.max(startY + (lineCount * 16) + 20, 100); + + // Apply constraints if current size is too small + let needsUpdate = false; + if (node.size[0] < finalMinWidth) { node.size[0] = finalMinWidth; needsUpdate = true; } + if (node.size[1] < finalMinHeight) { node.size[1] = finalMinHeight; needsUpdate = true; } + + if (needsUpdate && node.setDirtyCanvas) { + node.setDirtyCanvas(true, true); + } + }; + + if (immediate) { + executeEnforce(); + } else { + if (node._resizeTimer) clearTimeout(node._resizeTimer); + node._resizeTimer = setTimeout(executeEnforce, 150); + } +} + +/** + * Synchronizes the active LoRA state from the node's internal map to the hidden widget. + * This ensures the data is passed correctly to the backend when the workflow runs. + * Also updates the cached display names for the node's background rendering. + * + * @param {Object} nodeRef - The ComfyUI node instance. + */ +function syncLoraDataToWidget(nodeRef) { + const loraMap = nodeRef.properties.activeLorasMap || {}; + const fullCatalog = nodeRef.properties._fullLoraCatalog || []; + const activeList = []; + const activeDisplayNames = []; + + const sortedKeys = Object.keys(loraMap).sort(); + for (const key of sortedKeys) { + const item = nodeRef.properties.activeLorasMap[key]; + if (item && item.on) { + const fullLoraObj = fullCatalog.find(lora => lora.relative_path === key); + let finalDisplayName = key.split('/').pop(); + + // Prioritize metadata name for display + if (fullLoraObj) { + if (fullLoraObj.has_meta && fullLoraObj.meta && fullLoraObj.meta.name) { + finalDisplayName = fullLoraObj.meta.name; + } else if (fullLoraObj.display_name) { + finalDisplayName = fullLoraObj.display_name; + } + } + + activeDisplayNames.push(`${finalDisplayName} (${item.strength})`); + activeList.push({ + name: item.name, + subfolder: item.subfolder, + strength_model: item.strength, + strength_clip: item.strength + }); + } + } + + nodeRef.properties._cachedActiveDisplayNames = activeDisplayNames; + + // Update the hidden string widget with JSON data + const hiddenWidget = nodeRef.widgets.find(w => w.name === "lora_data"); + if (hiddenWidget) { + const jsonValue = JSON.stringify(activeList); + hiddenWidget.value = jsonValue; + if (hiddenWidget.callback) hiddenWidget.callback(jsonValue); + } else { + console.error("[Neurad] CRITICAL: Widget 'lora_data' not found!"); + } + + nodeRef.setDirtyCanvas(true, true); + enforceMinSize(nodeRef, true); +} + +/** + * Fetches the list of available LoRAs from the Python backend. + * @returns {Promise} List of LoRA objects. + */ +async function loadLoraList() { + try { + const response = await fetch('/neurad/get-loras'); + if (!response.ok) throw new Error("Network error: " + response.status); + return await response.json(); + } catch (error) { + console.error("[Neurad] Failed to load LoRAs:", error); + return []; + } +} + +/** + * Performs a complete reset of the cache system. + * Clears LocalStorage, memory cache, and requests server-side disk deletion. + * + * @param {Object} nodeRef - The node instance. + * @param {Function} refreshCallback - Callback to refresh UI after reset. + */ +async function resetAllCache(nodeRef, refreshCallback) { + if (!confirm("WARNING: This action is irreversible.\n\nThe following data will be permanently deleted:\nAll cached metadata (Names, Trigger Words, Images, Strength Recommendations).\nLocal disk saves.\nLocal browser cache.\n\nYou will need to re-download or manually re-enter the data for every LoRA.\n\nContinue?")) { + return; + } + + console.log("[Neurad] Starting full purge..."); + + // 1. Clear Browser LocalStorage + localStorage.removeItem(STORAGE_KEY); + LocalCache.data = {}; + console.log("[Neurad] Browser LocalStorage cleared."); + + // 2. Request Server Disk Purge + try { + const response = await fetch('/neurad/reset-cache', { method: 'POST' }); + const result = await response.json(); + + if (result.success) { + console.log("[Neurad] Server disk cache purged successfully."); + } else { + console.warn("[Neurad] Server purge reported issues:", result); + } + } catch (e) { + console.error("[Neurad] CRITICAL: Failed to contact server for purge", e); + alert("Server connection error.\nCache deletion may be incomplete. Some files might still remain."); + } + + // 3. Refresh UI + if (refreshCallback) { + refreshCallback(); + } + + alert("Cache cleared successfully\n\nRefresh the page or the interface to ensure no residual data is present."); +} + +// ============================================================================ +// MAIN UI ENTRY POINT: FLOATING PANEL +// ============================================================================ +/** + * Creates and opens the main floating panel (Modal) for the Visual LoRA Loader. + * + * Workflow: + * 1. Closes any existing panel instance. + * 2. Creates the modal overlay and "Clear Cache" button. + * 3. Loads data sequentially: LocalCache (Memory/Disk) -> LoRA Catalog (Server). + * 4. Initializes the Search Bar, Tabs, and Grid components. + * 5. Sets up the global refresh callback. + * + * @param {Object} nodeRef - The ComfyUI node instance. + */ +function createFloatingPanel(nodeRef) { + // Initialize default tab config if missing + if (!nodeRef.properties.tabsConfig) { + nodeRef.properties.tabsConfig = { + activeTabId: "library", + tabs: [{ id: "library", name: "Library", locked: true, type: "dynamic", loras: [] }] + }; + } + + // Close existing panel if open + if (nodeRef.neuradPanelOverlay && document.body.contains(nodeRef.neuradPanelOverlay)) { + const closeBtn = nodeRef.neuradPanelOverlay.querySelector("button"); + if(closeBtn) closeBtn.click(); + } + + const contentBody = document.createElement("div"); + contentBody.style.cssText = "display:flex; flex-direction:column; height:100%; overflow:hidden;"; + + const loadingDiv = document.createElement("div"); + loadingDiv.innerText = "Refreshing LoRA catalog..."; + loadingDiv.style.cssText = `color: ${CONFIG.COLORS.bg_panel}; text-align:center; margin-top:20px; font-size:14px;`; + contentBody.appendChild(loadingDiv); + + // Create the main modal overlay (Width forced to min(1200px, 90vw) for better grid fit) + const overlay = createPopup( + contentBody, + "Visual LoRA Loader", + () => { nodeRef.neuradPanelOverlay = null; }, + "min(1200px, 90vw)", + "85vh" + ); + + nodeRef.neuradPanelOverlay = overlay; + + // --- CLEAR CACHE BUTTON (Hidden by default, activates on long hover) --- + const purgeBtn = document.createElement("button"); + purgeBtn.innerHTML = `⚠️ Clear Cache`; + purgeBtn.disabled = true; // Initially disabled for safety + + // Dormant Style + purgeBtn.style.cssText = ` + position: relative; align-self: flex-start; margin: 0px; + z-index: 100; background: transparent; + color: ${CONFIG.COLORS.text_faded}; border: none; + font-size: 9px; font-weight: bold; cursor: default; + white-space: nowrap; outline: none; + transition: all 0.3s ease; opacity: 0.05; + `; + + let activationTimer = null; + + // Activate on Long Hover (1 second) + purgeBtn.onmouseenter = function() { + activationTimer = setTimeout(() => { + this.disabled = false; + this.style.cssText = ` + position: relative; align-self: flex-start; margin: 0px; + z-index: 100; background: red; color: white; + border: none; font-size: 9px; font-weight: bold; + cursor: pointer; white-space: nowrap; outline: none; + transition: all 0.3s ease; opacity: 1; + `; + }, 1000); + }; + + // Deactivate on Mouse Leave + purgeBtn.onmouseleave = function() { + if (activationTimer) clearTimeout(activationTimer); + this.disabled = true; + this.style.cssText = ` + position: relative; align-self: flex-start; margin: 0px; + z-index: 100; background: transparent; + color: ${CONFIG.COLORS.text_faded}; border: none; + font-size: 9px; font-weight: bold; cursor: default; + white-space: nowrap; outline: none; + transition: all 0.3s ease; opacity: 0.05; + `; + }; + + // Click Handler: Execute Cache Purge + purgeBtn.onclick = async function(e) { + if (this.disabled) return; + e.stopPropagation(); e.preventDefault(); + + if (!confirm("WARNING: This action is irreversible.\n\nThe following data will be permanently deleted:\nAll cached metadata (Images, Trigger Words).\nLocal disk saves.\nLocal browser cache.\n\nContinue?")) { + return; + } + + try { + // 1. Clear Browser LocalStorage + localStorage.removeItem(STORAGE_KEY); + if (typeof LocalCache !== 'undefined') LocalCache.data = {}; + + // 2. Call Server API to clear disk files + const response = await fetch('/neurad/reset-cache', { + method: 'POST', headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) throw new Error("Server error: " + response.status); + const result = await response.json(); + + if (result.success) { + alert("Cache cleared successfully\n\nRefresh the page or the interface to ensure no residual data is present."); + if (typeof refreshUI === 'function') refreshUI(); + } else { + throw new Error(result.error || "Unknown error"); + } + } catch (err) { + console.error("[Neurad] CRITICAL ERROR:", err); + alert("An error happened during the clearing: " + err.message); + } + + // Reset button state + this.disabled = true; + this.style.background = "transparent"; + this.style.opacity = "0.6"; + }; + + // --- SEQUENTIAL DATA LOADING --- + + // Step 2: Load Catalog and Render UI + const proceedWithCatalog = () => { + console.log("[Neurad] Step 2: Loading LoRA Catalog..."); + + loadLoraList().then(fullCatalog => { + nodeRef.properties._fullLoraCatalog = fullCatalog; + console.log(`[Neurad] Catalog loaded: ${fullCatalog.length} LoRAs.`); + + // Remove loading indicator + if (loadingDiv.parentNode) contentBody.removeChild(loadingDiv); + + const mainContainer = document.createElement("div"); + mainContainer.style.cssText = "display:flex; flex-direction:column; height:100%; overflow:hidden;"; + mainContainer.appendChild(purgeBtn); + contentBody.appendChild(mainContainer); + + // Initialize Search Config + if (!nodeRef.properties.searchConfig) { + nodeRef.properties.searchConfig = { query: "", excludeQuery: "", filters: [], lastActiveField: "positive" }; + } + const config = nodeRef.properties.searchConfig; + if (!Array.isArray(config.filters)) config.filters = []; + const validFilters = ["on", "off", "has_meta", "no_meta"]; + config.filters = config.filters.filter(f => validFilters.includes(f)); + + // Define Refresh UI Function + const refreshUI = () => { + // Preserve scroll positions + const tabBarElement = mainContainer.querySelector('.neurad-tab-bar'); + const savedTabScroll = (tabBarElement) ? tabBarElement.scrollLeft : 0; + let savedGridScroll = 0; + const currentGridContainer = mainContainer.lastElementChild; + if (currentGridContainer && currentGridContainer !== purgeBtn) { + if (currentGridContainer.scrollHeight > currentGridContainer.clientHeight) { + savedGridScroll = currentGridContainer.scrollTop; + } + } + + // Build new content in a detached fragment (prevents flickering) + const staging = document.createElement("div"); + staging.appendChild(purgeBtn); + renderSearchBar(nodeRef, staging, fullCatalog, refreshUI); + renderTabsBar(nodeRef, staging, fullCatalog, refreshUI, refreshUI, savedTabScroll); + renderLoraGrid(nodeRef, fullCatalog, staging, refreshUI, savedGridScroll); + + // Atomic swap + mainContainer.replaceChildren(...staging.childNodes); + }; + + window.neuradRefreshCallback = refreshUI; + refreshUI(); + + }).catch(err => { + console.error("[Neurad] Failed to load catalog", err); + loadingDiv.innerText = "Error loading catalog."; + }); + }; + + // Step 1: Load/Restore Cache + console.log("[Neurad] Step 1: Checking LocalCache status..."); + + if (LocalCache.data && Object.keys(LocalCache.data).length > 0) { + console.log("[Neurad] Cache already in memory. Proceeding."); + proceedWithCatalog(); + } else { + console.log("[Neurad] Cache empty. Triggering restore from disk..."); + LocalCache.restoreFromDisk().then(() => { + console.log("[Neurad] Restore finished. Proceeding."); + proceedWithCatalog(); + }).catch(e => { + console.warn("[Neurad] Restore failed, proceeding anyway.", e); + proceedWithCatalog(); + }); + } +} + +// ============================================================================ +// HELPER: FETCH SINGLE INFO +// ============================================================================ +/** + * Triggers a fetch request for a single LoRA's metadata from CivitAI. + * Updates the UI card with loading states, success, or error messages. + * + * @param {Object} nodeRef - The node instance. + * @param {Object} lora - The LoRA object to fetch info for. + * @param {HTMLElement} containerElement - The DOM element representing the card's image area. + * @param {Function} refreshCallback - Callback to refresh the grid upon success. + */ +async function triggerFetchSingleInfo(nodeRef, lora, containerElement, refreshCallback) { + // 1. Visual Feedback: Loading State + const originalContent = containerElement.innerHTML; + containerElement.innerHTML = ` +
⏳
+
Extracting ID...
+ `; + containerElement.style.pointerEvents = "none"; + containerElement.style.background = "#222"; + + try { + // Call Backend to fetch and parse CivitAI data + const response = await fetch('/neurad/fetch-single-info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: lora.relative_path }) + }); + + const result = await response.json(); + + if (result.success && result.meta) { + // SUCCESS: + // 1. Save to LocalStorage (triggers disk backup automatically) + LocalCache.set(lora.relative_path, result.meta); + + // 2. Update in-memory object for immediate UI reflection + lora.has_meta = true; + lora.meta = result.meta; + + // 3. Refresh UI + if (refreshCallback) { + refreshCallback(); + } + } else { + // FAILURE: Display error + const errorMsg = result.error || "Failed to fetch"; + containerElement.innerHTML = ` +
⚠️
+
${errorMsg}
+
Click to retry
+ `; + containerElement.style.pointerEvents = "auto"; + containerElement.style.background = "#2a2a2a"; + containerElement.onclick = (e) => { + e.stopPropagation(); + triggerFetchSingleInfo(nodeRef, lora, containerElement, refreshCallback); + }; + } + } catch (e) { + // NETWORK ERROR + containerElement.innerHTML = ` +
❌
+
Network Error
+ `; + containerElement.style.pointerEvents = "auto"; + containerElement.onclick = (e) => { + e.stopPropagation(); + triggerFetchSingleInfo(nodeRef, lora, containerElement, refreshCallback); + }; + } +} + +// ============================================================================ +// NODE REGISTRATION +// ============================================================================ +app.registerExtension({ + name: "Neurad.VisualLora", + + /** + * Setup Phase: Executed once when the extension loads. + * Restores cache from disk before any nodes are created. + */ + async setup(app) { + await LocalCache.restoreFromDisk(); + }, + + /** + * Node Definition Phase: Executed for every node type registered. + * Modifies the "NeuradVisualLora" node specifically. + */ + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "NeuradVisualLora") { + + // --- ON NODE CREATED --- + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + this.setSize([400, 300]); + + // Add "Open Library" Button Widget + this.addWidget("button", "Open Library", "Open", () => { + createFloatingPanel(this); + }); + + // Create Hidden Data Widget (Stores JSON for backend) + let dataWidget = this.widgets.find(w => w.name === "lora_data"); + if (!dataWidget) { + dataWidget = this.addWidget("string", "lora_data", "[]", () => {}, { hidden: true }); + } + + // COMPLETE NEUTRALIZATION OF HIDDEN WIDGET + // Ensures the widget takes up 0 space and ignores all interactions. + + // A. Force Size to Zero + dataWidget.computeSize = function(width) { return [0, 0]; }; + + // B. Override Draw (Return 0 height) + dataWidget.draw = function(ctx, node, widgetWidth, widgetY, widgetHeight) { return 0; }; + + // C. Override Mouse (Ignore clicks) + dataWidget.mouse = function(event, pos, node) { return false; }; + + // D. Hide DOM Element + if (dataWidget.element) { + dataWidget.element.style.display = "none"; + dataWidget.element.style.visibility = "hidden"; + dataWidget.element.style.pointerEvents = "none"; + if (document.activeElement === dataWidget.element) dataWidget.element.blur(); + } + + // E. Shield: Prevent getWidgetOnPos from detecting this widget + const originalGetWidgetOnPos = this.getWidgetOnPos; + this.getWidgetOnPos = function(x, y) { + const widget = originalGetWidgetOnPos ? originalGetWidgetOnPos.call(this, x, y) : null; + if (widget === dataWidget) return null; + return widget; + }; + + // F. Shield: Prevent onMouseDown from interacting with this widget + const originalOnMouseDown = this.onMouseDown; + this.onMouseDown = function(e) { + if (dataWidget.element && dataWidget.element.contains(e.target)) { + e.stopPropagation(); e.preventDefault(); return false; + } + if (originalOnMouseDown) return originalOnMouseDown.apply(this, arguments); + }; + + // Force Layout Update + requestAnimationFrame(() => { + this.setSize([this.size[0], this.size[1]]); + this.setDirtyCanvas(true, true); + }); + + // Initialize Properties + if (!this.properties.activeLorasMap) this.properties.activeLorasMap = {}; + + if (!this.properties.tabsConfig) { + this.properties.tabsConfig = { + activeTabId: "library", + tabs: [{ id: "library", name: "Library", locked: true, type: "dynamic", loras: [] }] + }; + } + + if (!this.properties.searchConfig) { + this.properties.searchConfig = { + query: "", excludeQuery: "", filters: [], lastActiveField: "positive" + }; + } + + this.properties._fullLoraCatalog = null; + + // --- BACKGROUND RENDERING (List of Active LoRAs) --- + this.onDrawBackground = function(ctx) { + const names = this.properties._cachedActiveDisplayNames || []; + ctx.save(); + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillStyle = "#aaa"; + + const startY = this.widgets.length > 0 + ? (this.widgets[this.widgets.length - 1].y + this.widgets[this.widgets.length - 1].height + 10) + : 40; + + const lineHeight = 16; + if (names.length === 0) { + ctx.fillText("No active LoRAs", 10, startY); + } else { + names.forEach((name, index) => { + ctx.fillText(name, 10, startY + (index * lineHeight)); + }); + } + ctx.restore(); + }; + + // --- RESIZE HANDLER --- + this._resizeTimer = null; + const originalOnResize = this.onResize; + this.onResize = function() { + if (originalOnResize) originalOnResize.apply(this, arguments); + enforceMinSize(this, false); + }; + + return r; + }; + + // --- ON NODE REMOVED --- + const onRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function() { + if (this.neuradPanel && document.body.contains(this.neuradPanel)) { + document.body.removeChild(this.neuradPanel); + } + if (onRemoved) onRemoved.apply(this, arguments); + }; + } + } +}); \ No newline at end of file