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, /** * One-time migration: earlier versions mirrored the full cache into * browser LocalStorage on every write, which grows unbounded as the * LoRA library grows and can eat into the browser's per-origin storage * quota (shared with ComfyUI's own workflow-draft autosave), causing * "Failed to save workflow draft" errors. * The on-disk backup (via /neurad/*-localstorage-backup) is the * source of truth now, so we just purge the stale LocalStorage key * if it's still hanging around from before this change. */ load() { try { if (localStorage.getItem(STORAGE_KEY) !== null) { localStorage.removeItem(STORAGE_KEY); console.log("[Neurad] Removed legacy LocalStorage cache (now using on-disk cache only)."); } } catch (e) { console.error("[Neurad] LocalStorage cleanup error", e); } }, /** * Persists current in-memory data to disk (debounced). * No longer mirrors to LocalStorage — the on-disk backup, read fresh * via restoreFromDisk() at startup, is the single persistence layer. */ save() { this.triggerDiskSave(); }, /** Retrieves a specific item from the cache by key. */ get(key) { return this.data[key] || null; }, /** * Removes an item from the cache and triggers a save. * Used to purge stale metadata for LoRAs that no longer exist on disk. * @param {string} key - The LoRA path identifier. */ delete(key) { if (Object.prototype.hasOwnProperty.call(this.data, key)) { delete this.data[key]; this.save(); } }, /** * 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) { // Load directly into in-memory cache. Disk is the sole // persistence layer now, so there's nothing to sync back // to LocalStorage. this.data = result.data; console.log(`[Neurad] SUCCESS: Loaded ${Object.keys(this.data).length} items from disk cache.`); } 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. If "no_tab" filter is active: Filters baseList to keep ONLY LoRAs not present in any custom tab. * 3. Applies Text Filters (Include/Exclude queries). * 4. Applies State Filters (On/Off/Has Meta/No Meta). * * @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); } } // --- NEW LOGIC: "NO_TAB" FILTER --- // If the "no_tab" filter is active, filter baseList to keep ONLY LoRAs // that do NOT belong to any custom (non-locked) tab. const isNoTabFilterActive = searchConfig.filters.includes("no_tab"); if (isNoTabFilterActive) { baseList = baseList.filter(lora => { const loraPath = lora.relative_path; // Check if this LoRA exists in ANY custom tab const isInCustomTab = config.tabs.some(t => { return !t.locked && t.type === "custom" && t.loras.includes(loraPath); }); // Keep only if it is NOT in a custom tab return !isInCustomTab; }); } // ---------------------------------- // 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 = []; } // Check if there are other filters besides "no_tab" const otherFilters = activeFilters.filter(f => f !== "no_tab"); // Return early if no other filters are active (Text, On/Off, Meta) // Note: The "no_tab" filter has already been applied to baseList above. if (!query && !excludeQuery && otherFilters.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]; // Skip "no_tab" here as it was already handled above if (filter === "no_tab") continue; 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")); // --- NEW BUTTON: Unassigned LoRAs Filter --- // Uses "∅" icon to represent "not in any tab" row2.appendChild(createFilterBtn(null, "➖", "no_tab")); // ------------------------------------------- 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 (loaded from node properties). * * @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. */ function renderTabsBar(nodeRef, container, currentCatalog, onTabChange, refreshCallback) { 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 FROM NODE PROPERTIES // (unless a tab was just created and needs the bar scrolled fully into view) requestAnimationFrame(() => { if (nodeRef._scrollTabsToEnd) { nodeRef._scrollTabsToEnd = false; if (tabBar.scrollWidth > tabBar.clientWidth) { tabBar.scrollLeft = tabBar.scrollWidth - tabBar.clientWidth; config.tabScrollX = tabBar.scrollLeft; saveTabsConfig(nodeRef); return; } } tabBar.scrollLeft = (config.tabScrollX || 0); }); // SAVE SCROLL POSITION ON CHANGE tabBar.addEventListener('scroll', () => { config.tabScrollX = tabBar.scrollLeft; saveTabsConfig(nodeRef); }); // MOUSE WHEEL: convert vertical wheel scroll into horizontal tab bar scroll // (wheel up -> scroll left, wheel down -> scroll right) tabBar.addEventListener('wheel', (e) => { if (e.deltaY === 0) return; e.preventDefault(); tabBar.scrollLeft += e.deltaY; }, { passive: false }); // 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(() => { // --- OPTIMIZATION: Avoid flicker unless necessary --- const isNoTabActive = (nodeRef.properties.searchConfig && nodeRef.properties.searchConfig.filters && nodeRef.properties.searchConfig.filters.includes("no_tab")); // If 'no_tab' filter is ON: The moved LoRA must disappear from the current view (Library). // A full refresh is required to re-filter the list. if (isNoTabActive) { if (refreshCallback) refreshCallback(); } // If 'no_tab' filter is OFF: The LoRA stays visible in Library (or we are in another tab). // No need to redraw the whole grid. Just update the target tab's counter badge. else { 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); nodeRef._scrollTabsToEnd = true; if (onTabChange) onTabChange(); } }; tabBar.appendChild(newTabBtn); container.appendChild(tabBar); } /** * Returns the URL of the first image in `images` that isn't listed in * `hiddenImages`, or null if there are no images or all of them are hidden. * Shared by the card grid thumbnail, the edit modal's cover-picker default, * and the info modal's cover badge -- so a hidden image is never silently * used as a fallback thumbnail anywhere in the UI. * * @param {Array} images - Array of {url, ...} objects. * @param {Array} hiddenImages - Array of hidden image URLs. * @returns {string|null} */ function getFirstVisibleImageUrl(images, hiddenImages) { if (!images || images.length === 0) return null; const hiddenSet = new Set(hiddenImages || []); const visible = images.find(img => !hiddenSet.has(img.url)); return visible ? visible.url : null; } // ============================================================================ // 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. * - Maintains scroll position from node properties. * * @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. */ function renderLoraGrid(nodeRef, fullCatalog, container, refreshCallback) { 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;`; // RESTORE SCROLL POSITION FROM NODE PROPERTIES requestAnimationFrame(() => { gridContainer.scrollTop = (nodeRef.properties.tabsConfig.gridScrollY || 0); }); // SAVE SCROLL POSITION ON CHANGE gridContainer.addEventListener('scroll', () => { nodeRef.properties.tabsConfig.gridScrollY = gridContainer.scrollTop; saveTabsConfig(nodeRef); }); // 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 = getFirstVisibleImageUrl(cachedMeta.images, cachedMeta.hiddenImages); // Priority 3: Cached First Visible Image } else if (lora.has_meta && lora.meta && lora.meta.images && lora.meta.images.length > 0) { imageUrl = getFirstVisibleImageUrl(lora.meta.images, lora.meta.hiddenImages); // Priority 4: Server First Visible 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); } // ============================================================================ // MODALS & POPUPS // ============================================================================ // Stack of currently-open popups' close functions (most recent last). // Lets Escape close only the topmost modal when several are stacked // (e.g. Info modal opened on top of the floating panel). const _popupStack = []; document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (_popupStack.length === 0) return; e.preventDefault(); e.stopPropagation(); const topClose = _popupStack[_popupStack.length - 1]; topClose(); }); /** * 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 = () => { const stackIdx = _popupStack.indexOf(closePopup); if (stackIdx !== -1) _popupStack.splice(stackIdx, 1); if (onCloseCallback) onCloseCallback(); document.removeEventListener('mousedown', handleOutsideClick); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; closeBtn.onclick = closePopup; _popupStack.push(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] : [], hiddenImages: freshData.hiddenImages ? [...freshData.hiddenImages] : [], 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