Files
DarthNeurad-ComfyUI-Visual-…/web/visual_lora_loader.js
T
DarthNeurad 1801697c89 Initial state
State at time of repository creation.
2026-07-28 20:11:14 +02:00

2618 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<strong style='font-size:16px; color:${CONFIG.COLORS.text_faded};'>Edit Mode</strong>`;
formContainer.appendChild(headerDiv);
// Field 1: Display Name
const nameGroup = document.createElement("div");
nameGroup.innerHTML = "<strong>Displayed Name</strong>";
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 = `<strong>Trigger Words</strong><div style='font-size:11px; color:${CONFIG.COLORS.text_faded}; margin-bottom:5px;'>Use semicolon as separator (;).</div>`;
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 = `<strong>Cover Image</strong><div style='font-size:11px; color:${CONFIG.COLORS.text_faded}; margin-bottom:5px;'>Custom URL :</div>`;
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 = `<strong>Recommended Strength</strong><div style='font-size:11px; color:${CONFIG.COLORS.text_faded}; margin-bottom:5px;'>Leave empty to hide</div>`;
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 = "<strong>Trigger Words:</strong>";
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 = "<strong>Images:</strong>";
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 = `<strong>Recommended Strength:</strong> ${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<Array>} 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 = `
<div style="font-size:24px; color:#4da6ff;">⏳</div>
<div style="font-size:10px; color:#aaa; font-weight:bold;">Extracting ID...</div>
`;
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 = `
<div style="font-size:24px; color:#ff6b6b;">⚠️</div>
<div style="font-size:9px; color:#ff6b6b; text-align:center; padding:2px; max-width:90%;">${errorMsg}</div>
<div style="font-size:9px; color:#aaa; margin-top:4px;">Click to retry</div>
`;
containerElement.style.pointerEvents = "auto";
containerElement.style.background = "#2a2a2a";
containerElement.onclick = (e) => {
e.stopPropagation();
triggerFetchSingleInfo(nodeRef, lora, containerElement, refreshCallback);
};
}
} catch (e) {
// NETWORK ERROR
containerElement.innerHTML = `
<div style="font-size:24px; color:#ff6b6b;">❌</div>
<div style="font-size:9px; color:#ff6b6b;">Network Error</div>
`;
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);
};
}
}
});