Files
ChrisandCursor 8e6e44976c Add per-LoRA variant trigger file selection in loader settings.
Support optional companion txt files named lora.Variant.txt alongside the default lora.txt, with toggles in the Grid and List LoRA loader settings dialog to merge character/concept tags into the active trigger list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:05:04 +01:00

1126 lines
35 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { bindLoraContextMenu, fetchLoraTriggerVariants } from "./lora_info.js";
import { bindPreviewImage, replaceWithPreviewPlaceholder } from "./lora_preview_cache.js";
import {
createEmptyTriggerEntry,
entryTriggerStates,
entryVariantStates,
formatEnabledTriggers,
initEntryTriggers,
loadEntryTriggerData,
LoraEntrySettingsDialog,
restoreTriggerEntryFromWidget,
} from "./lora_loader_settings.js";
const NODE_NAME = "ListLoraLoader";
const API_BASE = "/crashutils/loras";
const THUMB_DEFAULT = 56;
const THUMB_MIN = 40;
const THUMB_MAX = 96;
const THUMB_STEP = 4;
const DEFAULT_WIDTH = 400;
const DEFAULT_HEIGHT = 480;
const MIN_WIDTH = 300;
const MIN_HEIGHT = 240;
const PANEL_HEADER_HEIGHT = 26;
const PANEL_GAP = 6;
const BROWSE_PANEL_RATIO = 0.6;
const OPTIONAL_INPUT_SLOTS = 2;
const INPUT_SLOT_HEIGHT = 20;
const DEFAULT_CONTENT_HEIGHT = 240;
const NODE_BOTTOM_PAD = 18;
const TITLE_BAR_FALLBACK = 30;
const WIDGET_ROW_FALLBACK = 20;
const WIDGET_ROW_GAP = 4;
const STORAGE_KEY = "Comfy.ListLoraLoader.defaults";
function readGlobalDefaults() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
} catch {
return {};
}
}
function writeGlobalDefaults(props) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
sfw: props.sfw,
browsePath: props.browsePath,
thumbSize: props.thumbSize,
})
);
} catch {
// ignore
}
}
function applyGlobalDefaults(properties) {
const globals = readGlobalDefaults();
if (properties.sfw === undefined && globals.sfw !== undefined) {
properties.sfw = globals.sfw;
}
if (properties.thumbSize === undefined && globals.thumbSize !== undefined) {
properties.thumbSize = globals.thumbSize;
}
if (properties.browsePath === undefined && globals.browsePath !== undefined) {
properties.browsePath = globals.browsePath;
}
}
function clampThumbSize(value) {
const num = Number(value);
if (Number.isNaN(num)) return THUMB_DEFAULT;
return Math.min(THUMB_MAX, Math.max(THUMB_MIN, Math.round(num / THUMB_STEP) * THUMB_STEP));
}
function formatTriggers(triggers) {
if (!triggers?.length) return "";
return triggers.join(", ");
}
function isNsfwBrowsePath(path) {
return String(path || "")
.split(/[/\\]/)
.some((part) => part.toLowerCase() === "nsfw");
}
function trimNsfwBrowsePath(path) {
const parts = String(path || "")
.split(/[/\\]/)
.filter(Boolean);
const nsfwIndex = parts.findIndex((part) => part.toLowerCase() === "nsfw");
if (nsfwIndex === -1) {
return parts.join("/");
}
return parts.slice(0, nsfwIndex).join("/");
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatStrength(value) {
const num = Number(value);
if (Number.isNaN(num)) return "?";
if (Number.isInteger(num)) return String(num);
return num.toFixed(2).replace(/\.?0+$/, "");
}
function getWidgetTop(node) {
const domWidget = node.listLoraWidget;
if (domWidget?.last_y != null && domWidget.last_y > 0) {
return domWidget.last_y;
}
let yOffset = TITLE_BAR_FALLBACK + OPTIONAL_INPUT_SLOTS * INPUT_SLOT_HEIGHT;
for (const widget of node.widgets || []) {
if (widget === domWidget) break;
if (widget.computeSize) {
const [, h] = widget.computeSize();
yOffset += Math.max(h, 0) + WIDGET_ROW_GAP;
} else {
yOffset += WIDGET_ROW_FALLBACK + WIDGET_ROW_GAP;
}
}
return yOffset;
}
function getContentArea(node, nodeHeight) {
const widgetY = getWidgetTop(node);
const totalH = nodeHeight ?? node.size?.[1] ?? DEFAULT_HEIGHT;
return {
w: Math.max(MIN_WIDTH, (node.size?.[0] || DEFAULT_WIDTH) - 20),
h: Math.max(MIN_HEIGHT, totalH - widgetY - NODE_BOTTOM_PAD),
};
}
function syncLayoutToNode(node, nodeHeight) {
if (!node?.listLoraUI) return;
const { w, h } = getContentArea(node, nodeHeight);
if (node._listLoraLayoutW === w && node._listLoraLayoutH === h) return;
node._listLoraLayoutW = w;
node._listLoraLayoutH = h;
node.listLoraUI.layoutPanels(w, h);
}
function applyDefaultNodeSize(node) {
if (!node.size) {
node.size = [DEFAULT_WIDTH, DEFAULT_HEIGHT];
}
if (node.size[0] < DEFAULT_WIDTH) {
node.size[0] = DEFAULT_WIDTH;
}
if (node.size[1] < DEFAULT_HEIGHT) {
node.size[1] = DEFAULT_HEIGHT;
}
syncLayoutToNode(node, node.size[1]);
node.setDirtyCanvas(true, true);
}
const STYLES = `
.crash-list-lora {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 11px;
color: #ccc;
user-select: none;
box-sizing: border-box;
overflow: hidden;
position: relative;
}
.crash-list-lora * { box-sizing: border-box; }
.crash-list-lora .panel {
border: 1px solid #555;
border-radius: 4px;
background: #2a2a2a;
overflow: hidden;
position: absolute;
left: 0;
right: 0;
}
.crash-list-lora .panel-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
background: #333;
border-bottom: 1px solid #555;
height: ${PANEL_HEADER_HEIGHT}px;
}
.crash-list-lora .breadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.crash-list-lora .crumb {
color: #8af;
cursor: pointer;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.crash-list-lora .crumb:hover { text-decoration: underline; }
.crash-list-lora .crumb-sep { color: #666; margin: 0 1px; }
.crash-list-lora .search-box {
width: 72px;
min-width: 60px;
padding: 2px 6px;
border: 1px solid #555;
border-radius: 3px;
background: #1a1a1a;
color: #ddd;
font-size: 11px;
outline: none;
}
.crash-list-lora .search-box:focus { border-color: #8af; }
.crash-list-lora .header-controls {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.crash-list-lora .sfw-switch {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
flex-shrink: 0;
}
.crash-list-lora .sfw-label {
color: #aaa;
font-size: 10px;
white-space: nowrap;
}
.crash-list-lora .switch-track {
position: relative;
width: 32px;
height: 16px;
flex-shrink: 0;
}
.crash-list-lora .switch-track input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
}
.crash-list-lora .switch-thumb {
position: absolute;
inset: 0;
background: #555;
border-radius: 999px;
transition: background 0.15s;
}
.crash-list-lora .switch-thumb::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #ddd;
border-radius: 50%;
transition: transform 0.15s;
}
.crash-list-lora .switch-track input:checked + .switch-thumb {
background: #4a9;
}
.crash-list-lora .switch-track input:checked + .switch-thumb::after {
transform: translateX(16px);
}
.crash-list-lora .size-control {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.crash-list-lora .size-label {
color: #aaa;
font-size: 10px;
white-space: nowrap;
}
.crash-list-lora .size-slider {
width: 56px;
height: 14px;
margin: 0;
accent-color: #8af;
cursor: pointer;
}
.crash-list-lora .list-scroll {
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.crash-list-lora .list {
display: flex;
flex-direction: column;
gap: 4px;
}
.crash-list-lora .list-row {
display: flex;
align-items: stretch;
gap: 8px;
padding: 4px;
border: 1px solid #444;
border-radius: 4px;
background: #1e1e1e;
cursor: pointer;
position: relative;
min-height: calc(var(--thumb-size, 56px) + 8px);
transition: border-color 0.15s, opacity 0.15s;
}
.crash-list-lora .list-row:hover { border-color: #8af; }
.crash-list-lora .list-row.selected { border-color: #6c6; }
.crash-list-lora .list-row.disabled { opacity: 0.35; }
.crash-list-lora .list-row.folder-row {
align-items: center;
min-height: calc(var(--thumb-size, 56px) + 8px);
}
.crash-list-lora .row-thumb {
width: var(--thumb-size, 56px);
height: var(--thumb-size, 56px);
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
background: #141414;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.crash-list-lora .row-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.crash-list-lora .row-thumb .placeholder,
.crash-list-lora .row-thumb .folder-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: calc(var(--thumb-size, 56px) * 0.42);
}
.crash-list-lora .row-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 2px 0;
}
.crash-list-lora .row-name {
font-weight: 600;
color: #ddd;
font-size: 11px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.crash-list-lora .row-tags {
font-size: 9px;
color: #888;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.crash-list-lora .row-tags.empty { display: none; }
.crash-list-lora .row-subpath {
font-size: 9px;
color: #777;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.crash-list-lora .cog-btn {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border: none;
border-radius: 3px;
background: rgba(0,0,0,0.7);
color: #ccc;
font-size: 11px;
line-height: 18px;
text-align: center;
cursor: pointer;
padding: 0;
z-index: 2;
}
.crash-list-lora .cog-btn:hover { background: rgba(50,50,50,0.95); color: #fff; }
.crash-list-lora .strength-badge {
position: absolute;
bottom: 2px;
left: 2px;
padding: 1px 4px;
background: rgba(0,0,0,0.8);
color: #ffd866;
font-size: 9px;
font-weight: 700;
border-radius: 3px;
line-height: 1.2;
z-index: 2;
pointer-events: none;
}
.crash-list-lora .empty-msg {
color: #666;
text-align: center;
padding: 20px 10px;
font-style: italic;
}
.crash-list-lora .panel-label {
font-weight: 600;
color: #aaa;
white-space: nowrap;
}
`;
class HiddenLoraWidget {
constructor(name, value) {
this.name = name;
this.type = "custom";
this.value = value || { on: true, lora: null, strength: 1.0 };
}
draw() {}
computeSize() {
return [0, -4];
}
serializeValue() {
return { ...this.value };
}
}
class ListLoraLoaderUI {
constructor(node) {
this.node = node;
node.properties = node.properties || {};
this.currentPath = node.properties.browsePath || "";
this.searchQuery = node.properties.searchQuery || "";
this.searchTimer = null;
this.selected = new Map();
this.sfw = node.properties.sfw !== false;
this.thumbSize = clampThumbSize(node.properties.thumbSize ?? THUMB_DEFAULT);
this.root = document.createElement("div");
this.root.className = "crash-list-lora";
this.applyThumbSize(this.thumbSize);
const style = document.createElement("style");
style.textContent = STYLES;
this.root.appendChild(style);
const shell = document.createElement("div");
shell.innerHTML = `
<div class="panel browse-panel">
<div class="panel-header">
<span class="panel-label">Browse</span>
<div class="breadcrumbs"></div>
<div class="header-controls">
<label class="sfw-switch" title="Hide NSFW folders and their contents at any level">
<span class="sfw-label">SFW</span>
<span class="switch-track">
<input type="checkbox" class="sfw-checkbox" />
<span class="switch-thumb"></span>
</span>
</label>
<div class="size-control" title="Thumbnail size">
<span class="size-label">Size</span>
<input type="range" class="size-slider"
min="${THUMB_MIN}" max="${THUMB_MAX}" step="${THUMB_STEP}" />
</div>
<input class="search-box" type="text" placeholder="Search..." />
</div>
</div>
<div class="list-scroll browse">
<div class="list browse-list"></div>
</div>
</div>
<div class="panel selected-panel">
<div class="panel-header">
<span class="panel-label">Selected LoRAs</span>
</div>
<div class="list-scroll selected">
<div class="list selected-list"></div>
</div>
</div>
`;
this.root.appendChild(shell);
this.browsePanel = this.root.querySelector(".browse-panel");
this.selectedPanel = this.root.querySelector(".selected-panel");
this.browseScroll = this.root.querySelector(".list-scroll.browse");
this.selectedScroll = this.root.querySelector(".list-scroll.selected");
this.breadcrumbsEl = this.root.querySelector(".breadcrumbs");
this.searchEl = this.root.querySelector(".search-box");
this.sfwEl = this.root.querySelector(".sfw-checkbox");
this.sizeSliderEl = this.root.querySelector(".size-slider");
this.browseListEl = this.root.querySelector(".browse-list");
this.selectedListEl = this.root.querySelector(".selected-list");
this.sfwEl.checked = this.sfw;
this.sizeSliderEl.value = String(this.thumbSize);
this.searchEl.value = this.searchQuery;
this.sfwEl.addEventListener("change", () => {
this.sfw = this.sfwEl.checked;
if (this.sfw && isNsfwBrowsePath(this.currentPath)) {
this.currentPath = trimNsfwBrowsePath(this.currentPath);
}
this.saveProperties();
this.refreshBrowse();
});
this.bindPointerBlock(this.root.querySelector(".sfw-switch"));
this.sizeSliderEl.addEventListener("input", () => {
this.applyThumbSize(Number(this.sizeSliderEl.value));
this.saveProperties();
});
this.bindPointerBlock(this.sizeSliderEl);
this.bindPointerBlock(this.root.querySelector(".size-control"));
this.searchEl.addEventListener("input", () => {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.searchQuery = this.searchEl.value.trim();
this.saveProperties();
this.refreshBrowse();
}, 250);
});
this.bindPointerBlock(this.searchEl);
}
bindPointerBlock(el) {
if (!el) return;
el.addEventListener("pointerdown", (e) => e.stopPropagation());
el.addEventListener("click", (e) => e.stopPropagation());
}
applyThumbSize(size) {
this.thumbSize = clampThumbSize(size);
this.root.style.setProperty("--thumb-size", `${this.thumbSize}px`);
if (this.sizeSliderEl && this.sizeSliderEl.value !== String(this.thumbSize)) {
this.sizeSliderEl.value = String(this.thumbSize);
}
}
saveProperties() {
const props = this.node.properties || {};
props.sfw = this.sfw;
props.browsePath = this.currentPath;
props.thumbSize = this.thumbSize;
props.searchQuery = this.searchQuery;
this.node.properties = props;
writeGlobalDefaults(props);
this.node.setDirtyCanvas(true, true);
}
loadFromProperties(props = {}) {
if (props.sfw !== undefined) {
this.sfw = props.sfw !== false;
if (this.sfwEl) this.sfwEl.checked = this.sfw;
}
if (props.browsePath !== undefined) {
this.currentPath = props.browsePath || "";
}
if (this.sfw && isNsfwBrowsePath(this.currentPath)) {
this.currentPath = trimNsfwBrowsePath(this.currentPath);
}
if (props.thumbSize !== undefined) {
this.applyThumbSize(props.thumbSize);
}
if (props.searchQuery !== undefined) {
this.searchQuery = props.searchQuery || "";
if (this.searchEl) this.searchEl.value = this.searchQuery;
}
}
setBrowsePath(path) {
this.currentPath = path || "";
this.searchEl.value = "";
this.searchQuery = "";
this.node.properties.searchQuery = "";
this.saveProperties();
this.refreshBrowse();
}
sfwParam() {
return this.sfw ? "1" : "0";
}
layoutPanels(width, height) {
const w = Math.max(width, MIN_WIDTH);
const h = Math.max(height, MIN_HEIGHT);
this.root.style.width = `${w}px`;
this.root.style.height = `${h}px`;
const browsePanelHeight = Math.floor(h * BROWSE_PANEL_RATIO);
const selectedPanelHeight = h - browsePanelHeight - PANEL_GAP;
const scrollHeight = (panelHeight) => Math.max(panelHeight - PANEL_HEADER_HEIGHT, 40);
this.browsePanel.style.top = "0";
this.browsePanel.style.height = `${browsePanelHeight}px`;
this.browseScroll.style.top = `${PANEL_HEADER_HEIGHT}px`;
this.browseScroll.style.height = `${scrollHeight(browsePanelHeight)}px`;
this.selectedPanel.style.top = `${browsePanelHeight + PANEL_GAP}px`;
this.selectedPanel.style.height = `${selectedPanelHeight}px`;
this.selectedScroll.style.top = `${PANEL_HEADER_HEIGHT}px`;
this.selectedScroll.style.height = `${scrollHeight(selectedPanelHeight)}px`;
}
async refreshBrowse() {
if (this.searchQuery) {
await this.renderSearch();
} else {
await this.renderFolder();
}
}
async renderFolder() {
try {
const res = await fetch(
`${API_BASE}/browse?path=${encodeURIComponent(this.currentPath)}&sfw=${this.sfwParam()}`
);
const data = await res.json();
this.renderBreadcrumbs(data.breadcrumbs || []);
this.browseListEl.innerHTML = "";
const items = [];
for (const folder of data.folders || []) {
items.push({ type: "folder", ...folder });
}
for (const lora of data.loras || []) {
items.push({ type: "lora", ...lora });
}
if (items.length === 0) {
this.browseListEl.innerHTML = `<div class="empty-msg">No LoRAs in this folder</div>`;
return;
}
for (const item of items) {
this.browseListEl.appendChild(this.createBrowseRow(item));
}
} catch (err) {
this.browseListEl.innerHTML = `<div class="empty-msg">Failed to load folder</div>`;
console.error("[ListLoraLoader]", err);
}
}
async renderSearch() {
this.breadcrumbsEl.innerHTML = `<span style="color:#888">Search results</span>`;
this.browseListEl.innerHTML = `<div class="empty-msg">Searching...</div>`;
try {
const res = await fetch(
`${API_BASE}/search?path=${encodeURIComponent(this.currentPath)}&q=${encodeURIComponent(this.searchQuery)}&sfw=${this.sfwParam()}`
);
const data = await res.json();
this.browseListEl.innerHTML = "";
const loras = data.loras || [];
if (loras.length === 0) {
this.browseListEl.innerHTML = `<div class="empty-msg">No matches found</div>`;
return;
}
for (const lora of loras) {
this.browseListEl.appendChild(
this.createBrowseRow({ type: "lora", ...lora, showFolder: true })
);
}
} catch (err) {
this.browseListEl.innerHTML = `<div class="empty-msg">Search failed</div>`;
console.error("[ListLoraLoader]", err);
}
}
renderBreadcrumbs(crumbs) {
this.breadcrumbsEl.innerHTML = "";
crumbs.forEach((crumb, index) => {
if (index > 0) {
const sep = document.createElement("span");
sep.className = "crumb-sep";
sep.textContent = "/";
this.breadcrumbsEl.appendChild(sep);
}
const el = document.createElement("span");
el.className = "crumb";
el.textContent = crumb.name;
el.title = crumb.path || "loras";
el.addEventListener("click", (e) => {
e.stopPropagation();
this.setBrowsePath(crumb.path || "");
});
el.addEventListener("pointerdown", (e) => e.stopPropagation());
this.breadcrumbsEl.appendChild(el);
});
}
createThumbHtml(item) {
if (item.type === "folder") {
return `<div class="folder-icon">📁</div>`;
}
if (item.hasPreview) {
return `<img alt="" loading="lazy" />`;
}
return `<div class="placeholder">🎨</div>`;
}
createInfoHtml(item) {
if (item.type === "folder") {
return `<div class="row-name" title="${item.name}">${item.name}</div>`;
}
const tags = formatTriggers(item.triggers);
const tagsHtml = tags
? `<div class="row-tags" title="${escapeHtml(tags)}">${escapeHtml(tags)}</div>`
: `<div class="row-tags empty"></div>`;
const subpathHtml =
item.showFolder && item.folder
? `<div class="row-subpath" title="${escapeHtml(item.folder)}">${escapeHtml(item.folder)}</div>`
: "";
return `
<div class="row-name" title="${escapeHtml(item.path)}">${escapeHtml(item.name)}</div>
${subpathHtml}
${tagsHtml}
`;
}
createBrowseRow(item) {
const el = document.createElement("div");
el.className = `list-row${item.type === "folder" ? " folder-row" : ""}`;
if (item.type === "lora" && this.selected.has(item.path)) {
el.classList.add("selected");
}
el.innerHTML = `
<div class="row-thumb">${this.createThumbHtml(item)}</div>
<div class="row-info">${this.createInfoHtml(item)}</div>
`;
if (item.type === "folder") {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.setBrowsePath(item.path);
});
} else {
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
if (this.selected.has(item.path)) {
this.removeSelection(item.path);
} else {
this.addSelection(item);
}
});
bindLoraContextMenu(el, item.path);
this.ensureBrowseTriggers(item, el.querySelector(".row-tags"));
if (item.hasPreview) {
bindPreviewImage(el.querySelector(".row-thumb img"), item.path, replaceWithPreviewPlaceholder);
}
}
el.addEventListener("pointerdown", (e) => e.stopPropagation());
return el;
}
async ensureBrowseTriggers(item, tagsEl) {
if (!item?.path || !tagsEl || (item.triggers && item.triggers.length)) {
return;
}
const data = await fetchLoraTriggerVariants(item.path);
const triggers = data.default || [];
if (!triggers.length) {
return;
}
item.triggers = triggers;
const tags = formatTriggers(triggers);
tagsEl.classList.remove("empty");
tagsEl.textContent = tags;
tagsEl.title = tags;
}
addSelection(item) {
const loraPath = item.path;
if (this.selected.has(loraPath)) {
return;
}
const entry = createEmptyTriggerEntry({
on: true,
lora: loraPath,
name: item.name,
strength: 1.0,
});
initEntryTriggers(entry, item.triggers || []);
entry.defaultTriggers = item.triggers || [];
this.selected.set(loraPath, entry);
loadEntryTriggerData(entry).then(() => {
if (!this.selected.has(loraPath)) return;
this.renderSelected();
this.syncWidgets();
});
this.syncWidgets();
this.renderSelected();
this.refreshBrowse();
this.node.setDirtyCanvas(true, true);
}
removeSelection(loraPath) {
if (!this.selected.has(loraPath)) return;
this.selected.delete(loraPath);
this.syncWidgets();
this.renderSelected();
this.refreshBrowse();
this.node.setDirtyCanvas(true, true);
}
renderSelected() {
this.selectedListEl.innerHTML = "";
const entries = [...this.selected.values()];
if (entries.length === 0) {
this.selectedListEl.innerHTML = `<div class="empty-msg">Double-click LoRAs above to add them</div>`;
return;
}
for (const entry of entries) {
this.selectedListEl.appendChild(this.createSelectedRow(entry));
}
}
createSelectedRow(entry) {
const el = document.createElement("div");
el.className = "list-row";
if (!entry.on) {
el.classList.add("disabled");
}
const tags = formatTriggers(formatEnabledTriggers(entry));
const tagsHtml = tags
? `<div class="row-tags" title="${escapeHtml(tags)}">${escapeHtml(tags)}</div>`
: `<div class="row-tags empty"></div>`;
el.innerHTML = `
<div class="row-thumb">
<img alt="" loading="lazy" />
<span class="strength-badge" title="LoRA strength">${formatStrength(entry.strength)}</span>
</div>
<div class="row-info">
<div class="row-name" title="${escapeHtml(entry.lora)}">${escapeHtml(entry.name || entry.lora.split("/").pop())}</div>
${tagsHtml}
</div>
<button class="cog-btn" title="LoRA settings">⚙</button>
`;
el.querySelector(".cog-btn").addEventListener("click", (e) => {
e.stopPropagation();
this.openSettings(entry);
});
el.querySelector(".cog-btn").addEventListener("pointerdown", (e) => e.stopPropagation());
bindPreviewImage(el.querySelector(".row-thumb img"), entry.lora, replaceWithPreviewPlaceholder);
let clickTimer = null;
el.addEventListener("click", (e) => {
if (e.target.classList.contains("cog-btn")) return;
e.stopPropagation();
if (clickTimer) clearTimeout(clickTimer);
clickTimer = setTimeout(() => {
clickTimer = null;
entry.on = !entry.on;
this.syncWidgets();
this.renderSelected();
this.node.setDirtyCanvas(true, true);
}, 250);
});
el.addEventListener("dblclick", (e) => {
if (e.target.classList.contains("cog-btn")) return;
e.stopPropagation();
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
this.removeSelection(entry.lora);
});
bindLoraContextMenu(el, entry.lora, [
null,
{
content: entry.on ? "⚫ Disable" : "🟢 Enable",
callback: () => {
entry.on = !entry.on;
this.syncWidgets();
this.renderSelected();
this.node.setDirtyCanvas(true, true);
},
},
{
content: "🗑️ Remove",
callback: () => this.removeSelection(entry.lora),
},
]);
el.addEventListener("pointerdown", (e) => e.stopPropagation());
return el;
}
openSettings(entry) {
new LoraEntrySettingsDialog(entry, {
onApply: () => {
this.syncWidgets();
this.renderSelected();
this.node.setDirtyCanvas(true, true);
},
}).open();
}
syncWidgets() {
const node = this.node;
const domWidget = node.widgets?.find((w) => w.name === "list_lora_ui");
const others = (node.widgets || []).filter(
(w) => w !== domWidget && (!w.name || !w.name.startsWith("lora_"))
);
const loraWidgets = [];
let counter = 0;
for (const entry of this.selected.values()) {
counter++;
loraWidgets.push(
new HiddenLoraWidget(`lora_${counter}`, {
on: entry.on,
lora: entry.lora,
strength: entry.strength,
triggerStates: entryTriggerStates(entry),
variantStates: entryVariantStates(entry),
})
);
}
node.widgets = domWidget ? [domWidget, ...loraWidgets, ...others] : [...loraWidgets, ...others];
}
async refreshSelectedTriggers() {
const tasks = [...this.selected.values()].map(async (entry) => {
await loadEntryTriggerData(entry);
});
await Promise.all(tasks);
this.renderSelected();
this.syncWidgets();
}
loadFromWidgetValues(widgetValues, properties) {
this.selected.clear();
for (const value of widgetValues || []) {
if (value && typeof value === "object" && value.lora) {
const name = value.lora.split("/").pop().replace(/\.[^.]+$/, "");
const entry = createEmptyTriggerEntry({
on: value.on !== false,
lora: value.lora,
name,
strength: value.strength ?? 1.0,
});
restoreTriggerEntryFromWidget(entry, value);
this.selected.set(value.lora, entry);
}
}
this.loadFromProperties(properties ?? this.node.properties);
this.syncWidgets();
this.renderSelected();
this.refreshBrowse();
this.refreshSelectedTriggers();
}
}
app.registerExtension({
name: "Comfy.ListLoraLoader",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
if (this.listLoraInitialized) return result;
this.listLoraInitialized = true;
this.properties = this.properties || {};
applyGlobalDefaults(this.properties);
if (this.properties.sfw === undefined) {
this.properties.sfw = true;
}
if (this.properties.thumbSize === undefined) {
this.properties.thumbSize = THUMB_DEFAULT;
}
if (this.properties.browsePath === undefined) {
this.properties.browsePath = "";
}
if (this.properties.searchQuery === undefined) {
this.properties.searchQuery = "";
}
this.serialize_widgets = true;
this.widgets = this.widgets || [];
this.resizable = true;
const ui = new ListLoraLoaderUI(this);
this.listLoraUI = ui;
const nodeRef = this;
const widget = this.addDOMWidget("list_lora_ui", "ListLoraLoader", ui.root, {
serialize: false,
hideOnZoom: false,
});
this.listLoraWidget = widget;
widget.computeSize = function (width) {
const nodeWidth = nodeRef.size?.[0] || width || DEFAULT_WIDTH;
return [Math.max(MIN_WIDTH, nodeWidth - 20), DEFAULT_CONTENT_HEIGHT];
};
ui.refreshBrowse();
ui.renderSelected();
if (this._pendingLoraValues) {
ui.loadFromWidgetValues(this._pendingLoraValues, this._pendingProperties);
this._pendingLoraValues = null;
this._pendingProperties = null;
}
requestAnimationFrame(() => {
applyDefaultNodeSize(this);
if (!this.listLoraWidget?.last_y) {
requestAnimationFrame(() => applyDefaultNodeSize(this));
}
});
return result;
};
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (onResize) {
onResize.apply(this, arguments);
}
syncLayoutToNode(this, size?.[1]);
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
syncLayoutToNode(this);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const result = onConfigure ? onConfigure.apply(this, arguments) : undefined;
const loraValues = (info.widgets_values || []).filter(
(v) => v && typeof v === "object" && v.lora !== undefined
);
if (this.listLoraUI) {
this.listLoraUI.loadFromWidgetValues(loraValues, info.properties);
requestAnimationFrame(() => applyDefaultNodeSize(this));
} else {
this._pendingLoraValues = loraValues;
this._pendingProperties = info.properties;
}
return result;
};
const origConfigure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
if (origConfigure) {
origConfigure.apply(this, arguments);
}
const loraValues = (info.widgets_values || []).filter(
(v) => v && typeof v === "object" && v.lora !== undefined
);
if (this.listLoraUI) {
this.listLoraUI.loadFromWidgetValues(loraValues, info.properties);
requestAnimationFrame(() => applyDefaultNodeSize(this));
} else {
this._pendingLoraValues = loraValues;
this._pendingProperties = info.properties;
}
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (onRemoved) onRemoved.apply(this, arguments);
};
},
});