Rows pointing at a lora that's no longer on disk (moved, renamed, or deleted) now render red with a warning marker instead of only logging to the console at generation time. The missing row's name is click-to-replace: clicking it reopens the folder browser to swap in a working file in place. Detection reuses the existing /tree endpoint - its full paths are flattened into a set of valid loras, and a row is flagged when its lora isn't in that set. Fails safe (flags nothing until the list is known / if the fetch fails) and tolerates path-separator differences across OSes. Documents the feature in the README with a screenshot and promotes it out of the roadmap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1493 lines
50 KiB
JavaScript
1493 lines
50 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
// Injected once. Note the `box-sizing: border-box` + width:100% (not 100%+margin)
|
|
// on the add-lora button — this is what actually fixes the rgthree overflow bug,
|
|
// where the button's own padding/border pushed it past the node's rounded edge.
|
|
|
|
const STYLE_ID = "ultimate-lora-loader-styles";
|
|
if (!document.getElementById(STYLE_ID)) {
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
.fll-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-sizing: border-box;
|
|
padding: 6px;
|
|
gap: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fll-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: #2a2a2e;
|
|
border-radius: 6px;
|
|
padding: 4px 6px;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.fll-row.disabled {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.fll-row.enabled-highlight {
|
|
background: #2f2b3d;
|
|
border-color: #6d5aa8;
|
|
}
|
|
|
|
/* Missing on disk - wins over enabled-highlight's background/border so a
|
|
broken row reads as broken whether it's toggled on or off. */
|
|
.fll-row.missing,
|
|
.fll-row.missing.enabled-highlight {
|
|
background: #3a2323;
|
|
border-color: #c0392b;
|
|
}
|
|
.fll-name.fll-name-missing {
|
|
color: #ff8a80;
|
|
cursor: pointer;
|
|
}
|
|
.fll-name.fll-name-missing:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* --- Toggle switch (replaces checkbox) --- */
|
|
.fll-toggle-switch {
|
|
position: relative;
|
|
flex: 0 0 30px;
|
|
width: 30px;
|
|
height: 16px;
|
|
border-radius: 999px;
|
|
background: #46464c;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
display: inline-block;
|
|
}
|
|
.fll-toggle-switch.on {
|
|
background: #a78bfa;
|
|
}
|
|
.fll-toggle-switch .fll-toggle-knob {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #e8e8ea;
|
|
transition: left 0.15s ease;
|
|
}
|
|
.fll-toggle-switch.on .fll-toggle-knob {
|
|
left: 16px;
|
|
}
|
|
|
|
.fll-name {
|
|
flex: 1 1 auto;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: #ddd;
|
|
font-size: 12px;
|
|
cursor: default;
|
|
}
|
|
|
|
.fll-drag-handle {
|
|
flex: 0 0 16px;
|
|
width: 16px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #777;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
.fll-drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
.fll-drag-handle svg {
|
|
width: 10px;
|
|
height: 14px;
|
|
fill: currentColor;
|
|
}
|
|
|
|
.fll-priority {
|
|
flex: 0 0 26px;
|
|
width: 26px;
|
|
background: #1c1c1f;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
color: #999;
|
|
font-size: 11px;
|
|
text-align: center;
|
|
padding: 2px 0;
|
|
box-sizing: border-box;
|
|
cursor: pointer;
|
|
}
|
|
.fll-priority:focus {
|
|
outline: none;
|
|
border-color: #6d5aa8;
|
|
color: #ddd;
|
|
}
|
|
.fll-priority::-webkit-outer-spin-button,
|
|
.fll-priority::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
.fll-priority[type="number"] {
|
|
-moz-appearance: textfield;
|
|
}
|
|
|
|
.fll-row.dragging {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.fll-row.drop-target-above {
|
|
border-top: 2px solid #a78bfa;
|
|
}
|
|
.fll-row.drop-target-below {
|
|
border-bottom: 2px solid #a78bfa;
|
|
}
|
|
|
|
.fll-strength-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 0 0 74px;
|
|
width: 74px;
|
|
background: #1c1c1f;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fll-strength-arrow {
|
|
flex: 0 0 18px;
|
|
width: 18px;
|
|
height: 22px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #999;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-size: 10px;
|
|
}
|
|
.fll-strength-arrow:hover {
|
|
background: #2f2f34;
|
|
color: #ddd;
|
|
}
|
|
|
|
.fll-strength {
|
|
flex: 1 1 auto;
|
|
width: 100%;
|
|
min-width: 0;
|
|
background: transparent;
|
|
border: none;
|
|
color: #ddd;
|
|
font-size: 11px;
|
|
text-align: center;
|
|
padding: 2px 0;
|
|
box-sizing: border-box;
|
|
cursor: pointer;
|
|
}
|
|
.fll-strength:focus {
|
|
outline: none;
|
|
}
|
|
/* hide native number spinner - we draw our own arrows */
|
|
.fll-strength::-webkit-outer-spin-button,
|
|
.fll-strength::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
.fll-strength[type="number"] {
|
|
-moz-appearance: textfield;
|
|
}
|
|
|
|
.fll-remove {
|
|
flex: 0 0 20px;
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #999;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
}
|
|
.fll-remove:hover {
|
|
background: #3a3a3e;
|
|
color: #f87171;
|
|
}
|
|
.fll-remove svg {
|
|
width: 13px;
|
|
height: 13px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.6;
|
|
}
|
|
|
|
/* --- Header row (toggle-all / column labels / delete-all) --- */
|
|
/* Matches .fll-row's column widths/gap/padding exactly so the toggle-all
|
|
switch and delete-all icon line up with the switch/trash columns below. */
|
|
.fll-header-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 2px 6px;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
}
|
|
.fll-header-label {
|
|
font-size: 10px;
|
|
color: #888;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.fll-header-label.name-label {
|
|
flex: 1 1 auto;
|
|
}
|
|
.fll-header-label.strength-label {
|
|
flex: 0 0 74px;
|
|
width: 74px;
|
|
text-align: center;
|
|
}
|
|
.fll-header-toggle-slot {
|
|
flex: 0 0 30px;
|
|
width: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.fll-header-remove-slot {
|
|
flex: 0 0 20px;
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #888;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
}
|
|
.fll-header-remove-slot:hover {
|
|
background: #3a3a3e;
|
|
color: #f87171;
|
|
}
|
|
.fll-header-remove-slot svg {
|
|
width: 12px;
|
|
height: 12px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.6;
|
|
}
|
|
|
|
.fll-add-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 6px 0;
|
|
background: #333338;
|
|
border: 1px solid #46464c;
|
|
border-radius: 6px;
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.fll-add-btn:hover {
|
|
background: #3d3d43;
|
|
border-color: #5a5a62;
|
|
}
|
|
|
|
/* --- Folder browser popup --- */
|
|
|
|
.fll-popup-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 10000;
|
|
background: rgba(0,0,0,0.35);
|
|
}
|
|
|
|
.fll-popup {
|
|
position: fixed;
|
|
z-index: 10001;
|
|
min-width: 260px;
|
|
max-width: 360px;
|
|
max-height: 420px;
|
|
overflow-y: auto;
|
|
background: #232326;
|
|
border: 1px solid #46464c;
|
|
border-radius: 8px;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
padding: 4px;
|
|
box-sizing: border-box;
|
|
font-size: 12px;
|
|
color: #ddd;
|
|
}
|
|
|
|
.fll-breadcrumbs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 4px 6px 8px 6px;
|
|
border-bottom: 1px solid #38383c;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.fll-crumb {
|
|
cursor: pointer;
|
|
color: #a78bfa;
|
|
white-space: nowrap;
|
|
}
|
|
.fll-crumb:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.fll-crumb-sep {
|
|
color: #666;
|
|
margin: 0 2px;
|
|
}
|
|
.fll-crumb.current {
|
|
color: #ddd;
|
|
cursor: default;
|
|
}
|
|
.fll-crumb.current:hover {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.fll-entry {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 8px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
}
|
|
.fll-entry:hover {
|
|
background: #34343a;
|
|
}
|
|
.fll-entry-icon {
|
|
flex: 0 0 16px;
|
|
text-align: center;
|
|
opacity: 0.85;
|
|
}
|
|
.fll-entry-label {
|
|
flex: 1 1 auto;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.fll-entry-arrow {
|
|
flex: 0 0 auto;
|
|
color: #888;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.fll-empty {
|
|
padding: 14px 8px;
|
|
color: #888;
|
|
text-align: center;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lora tree fetch (cached per session, refetch on popup open if stale)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _treeCache = null;
|
|
let _treeCacheTime = 0;
|
|
const TREE_CACHE_MS = 15000; // refresh at most every 15s so new files show up reasonably fast
|
|
|
|
// Flat set of every valid lora path the backend reports (the exact strings
|
|
// get_filename_list("loras") returns, which is what an entry's `lora` field
|
|
// stores). Stays null until the first successful tree fetch so we never flag
|
|
// rows as "missing" before we actually know what's on disk.
|
|
let validLoraPaths = null;
|
|
|
|
function _normLora(p) {
|
|
// Normalize separators so a workflow saved on one OS doesn't read as
|
|
// "missing" on another; the flag is purely cosmetic so being lenient here
|
|
// only ever avoids false positives.
|
|
return String(p).replace(/\\/g, "/");
|
|
}
|
|
|
|
// Walk the nested tree collecting every value in every __full_paths__ map -
|
|
// those values are the full relative paths, i.e. the source of truth for
|
|
// what's installed. Rebuilt on each tree fetch so added/removed files show up.
|
|
function _rebuildValidLoraPaths(tree) {
|
|
const set = new Set();
|
|
(function walk(node) {
|
|
if (!node || typeof node !== "object") return;
|
|
const fp = node.__full_paths__;
|
|
if (fp) {
|
|
for (const k in fp) set.add(_normLora(fp[k]));
|
|
}
|
|
for (const key in node) {
|
|
if (key === "__files__" || key === "__full_paths__") continue;
|
|
walk(node[key]);
|
|
}
|
|
})(tree);
|
|
validLoraPaths = set;
|
|
}
|
|
|
|
// True only when we have a loaded list AND this lora isn't in it. Unknown
|
|
// (list not loaded yet / fetch failed) -> false, so we fail safe toward
|
|
// "not missing" rather than lighting up every row red on a transient hiccup.
|
|
function isLoraMissing(loraName) {
|
|
if (!validLoraPaths || !loraName) return false;
|
|
return !validLoraPaths.has(_normLora(loraName));
|
|
}
|
|
|
|
async function fetchLoraTree(force = false) {
|
|
const now = Date.now();
|
|
if (!force && _treeCache && now - _treeCacheTime < TREE_CACHE_MS) {
|
|
return _treeCache;
|
|
}
|
|
try {
|
|
const res = await api.fetchApi("/ultimate_lora_loader/tree");
|
|
const data = await res.json();
|
|
_treeCache = data;
|
|
_treeCacheTime = now;
|
|
_rebuildValidLoraPaths(data);
|
|
return data;
|
|
} catch (e) {
|
|
console.error("[UltimateLoraLoader] Failed to fetch lora tree", e);
|
|
return _treeCache || { __files__: [] };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Folder browser popup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Opens a folder-drill-down popup anchored near (x, y).
|
|
* Calls onSelect(fullRelativePath) when a file is chosen.
|
|
*/
|
|
function openLoraBrowser(x, y, onSelect) {
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "fll-popup-overlay";
|
|
|
|
const popup = document.createElement("div");
|
|
popup.className = "fll-popup";
|
|
|
|
overlay.appendChild(popup);
|
|
document.body.appendChild(overlay);
|
|
|
|
// position, clamped to viewport
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
popup.style.left = Math.min(x, vw - 380) + "px";
|
|
popup.style.top = Math.min(y, vh - 440) + "px";
|
|
|
|
const close = () => overlay.remove();
|
|
overlay.addEventListener("mousedown", (e) => {
|
|
if (e.target === overlay) close();
|
|
});
|
|
|
|
let path = []; // array of folder names representing current drill-down position
|
|
|
|
fetchLoraTree().then((tree) => {
|
|
renderLevel(tree);
|
|
});
|
|
|
|
function renderLevel(tree) {
|
|
// walk to current path
|
|
let node = tree;
|
|
for (const seg of path) {
|
|
node = node[seg];
|
|
if (!node) {
|
|
// path became invalid (e.g. folder deleted) - reset to root
|
|
path = [];
|
|
node = tree;
|
|
break;
|
|
}
|
|
}
|
|
|
|
popup.innerHTML = "";
|
|
|
|
// breadcrumbs
|
|
const crumbs = document.createElement("div");
|
|
crumbs.className = "fll-breadcrumbs";
|
|
|
|
const rootCrumb = document.createElement("span");
|
|
rootCrumb.className = "fll-crumb" + (path.length === 0 ? " current" : "");
|
|
rootCrumb.textContent = "loras";
|
|
rootCrumb.onclick = () => {
|
|
path = [];
|
|
renderLevel(tree);
|
|
};
|
|
crumbs.appendChild(rootCrumb);
|
|
|
|
path.forEach((seg, idx) => {
|
|
const sep = document.createElement("span");
|
|
sep.className = "fll-crumb-sep";
|
|
sep.textContent = "/";
|
|
crumbs.appendChild(sep);
|
|
|
|
const crumb = document.createElement("span");
|
|
const isCurrent = idx === path.length - 1;
|
|
crumb.className = "fll-crumb" + (isCurrent ? " current" : "");
|
|
crumb.textContent = seg;
|
|
crumb.onclick = () => {
|
|
path = path.slice(0, idx + 1);
|
|
renderLevel(tree);
|
|
};
|
|
crumbs.appendChild(crumb);
|
|
});
|
|
|
|
popup.appendChild(crumbs);
|
|
|
|
// subfolders (any key that isn't __files__ / __full_paths__)
|
|
const subfolders = Object.keys(node)
|
|
.filter((k) => k !== "__files__" && k !== "__full_paths__")
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
const files = (node.__files__ || []).slice().sort((a, b) => a.localeCompare(b));
|
|
const fullPaths = node.__full_paths__ || {};
|
|
|
|
if (subfolders.length === 0 && files.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "fll-empty";
|
|
empty.textContent = "No LoRAs here";
|
|
popup.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
for (const folder of subfolders) {
|
|
const entry = document.createElement("div");
|
|
entry.className = "fll-entry";
|
|
|
|
const icon = document.createElement("span");
|
|
icon.className = "fll-entry-icon";
|
|
icon.textContent = "📁";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "fll-entry-label";
|
|
label.textContent = folder;
|
|
|
|
const arrow = document.createElement("span");
|
|
arrow.className = "fll-entry-arrow";
|
|
arrow.textContent = "▸";
|
|
|
|
entry.appendChild(icon);
|
|
entry.appendChild(label);
|
|
entry.appendChild(arrow);
|
|
|
|
entry.onclick = () => {
|
|
path = [...path, folder];
|
|
renderLevel(tree);
|
|
};
|
|
|
|
popup.appendChild(entry);
|
|
}
|
|
|
|
for (const file of files) {
|
|
const entry = document.createElement("div");
|
|
entry.className = "fll-entry";
|
|
|
|
const icon = document.createElement("span");
|
|
icon.className = "fll-entry-icon";
|
|
icon.textContent = "🔹";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "fll-entry-label";
|
|
label.textContent = file;
|
|
label.title = file;
|
|
|
|
entry.appendChild(icon);
|
|
entry.appendChild(label);
|
|
|
|
entry.onclick = () => {
|
|
const full = fullPaths[file] || file;
|
|
onSelect(full);
|
|
close();
|
|
};
|
|
|
|
popup.appendChild(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node widget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeRow(entry, { onChange, onRemove, onReplace, showClipStrength, onDragStart, onDragOver, onDrop, onDragEnd, onPriorityChange, currentPriority }) {
|
|
const row = document.createElement("div");
|
|
row.className = rowClassName(entry);
|
|
// Row itself isn't draggable - only the dedicated drag handle icon
|
|
// triggers a drag, so clicking the toggle/priority/strength/trash
|
|
// controls never gets mistaken for a drag gesture. The row still needs
|
|
// to be a drop target though, so drag events are wired on `row` for
|
|
// dragover/drop, and on `dragHandle` for dragstart specifically.
|
|
row.ondragover = (e) => {
|
|
e.preventDefault();
|
|
onDragOver?.(e, row);
|
|
};
|
|
row.ondrop = (e) => {
|
|
e.preventDefault();
|
|
onDrop?.(e, row);
|
|
};
|
|
|
|
const dragHandle = document.createElement("div");
|
|
dragHandle.className = "fll-drag-handle";
|
|
dragHandle.innerHTML = `<svg viewBox="0 0 10 16" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="2" cy="2" r="1.4"/><circle cx="8" cy="2" r="1.4"/>
|
|
<circle cx="2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/>
|
|
<circle cx="2" cy="14" r="1.4"/><circle cx="8" cy="14" r="1.4"/>
|
|
</svg>`;
|
|
dragHandle.draggable = true;
|
|
dragHandle.ondragstart = (e) => {
|
|
e.dataTransfer.effectAllowed = "move";
|
|
// Firefox requires setData to be called for drag to actually start
|
|
e.dataTransfer.setData("text/plain", "");
|
|
onDragStart?.(e, row);
|
|
};
|
|
dragHandle.ondragend = (e) => {
|
|
onDragEnd?.(e, row);
|
|
};
|
|
|
|
const priority = document.createElement("input");
|
|
priority.type = "number";
|
|
priority.className = "fll-priority";
|
|
priority.step = "1";
|
|
priority.min = "1";
|
|
priority.readOnly = true;
|
|
priority.value = String(currentPriority ?? 1);
|
|
|
|
function applyPriority(raw) {
|
|
const parsed = parseInt(raw, 10);
|
|
if (!isNaN(parsed)) {
|
|
onPriorityChange?.(parsed);
|
|
}
|
|
}
|
|
|
|
function openPriorityDialog(event) {
|
|
openValueDialog({
|
|
event,
|
|
label: "Priority",
|
|
currentValue: () => currentPriority ?? 1,
|
|
formatValue: (v) => String(v),
|
|
applyValue: applyPriority,
|
|
});
|
|
}
|
|
|
|
priority.onclick = (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
priority.blur();
|
|
openPriorityDialog(e);
|
|
};
|
|
priority.onfocus = (e) => {
|
|
priority.blur();
|
|
openPriorityDialog(e);
|
|
};
|
|
priority.onchange = () => {
|
|
applyPriority(priority.value);
|
|
};
|
|
|
|
const toggle = document.createElement("div");
|
|
toggle.className = "fll-toggle-switch" + (entry.enabled ? " on" : "");
|
|
const knob = document.createElement("div");
|
|
knob.className = "fll-toggle-knob";
|
|
toggle.appendChild(knob);
|
|
toggle.onclick = () => {
|
|
entry.enabled = !entry.enabled;
|
|
toggle.className = "fll-toggle-switch" + (entry.enabled ? " on" : "");
|
|
row.className = rowClassName(entry);
|
|
onChange();
|
|
};
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "fll-name";
|
|
const shortName = entry.lora.split("/").pop();
|
|
const missing = isLoraMissing(entry.lora);
|
|
name.textContent = missing ? "⚠ " + shortName : shortName;
|
|
if (missing) {
|
|
name.classList.add("fll-name-missing");
|
|
name.title = `File not found on disk:\n${entry.lora}\n\nClick to choose a replacement.`;
|
|
// Click-to-replace: reopen the same folder browser and swap this row's
|
|
// lora in place. Cheap stand-in for the roadmap's "click-to-search"
|
|
// until a real search box exists.
|
|
name.onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (!onReplace) return;
|
|
const rect = name.getBoundingClientRect();
|
|
openLoraBrowser(rect.left, rect.bottom + 4, (loraPath) => onReplace(loraPath));
|
|
};
|
|
} else {
|
|
name.title = entry.lora;
|
|
}
|
|
|
|
const STRENGTH_STEP = 0.05;
|
|
|
|
function focusAndSelectDialogInput() {
|
|
// The dialog mounts asynchronously (it's a Vue component), so grab
|
|
// its input on the next couple of frames rather than assuming it
|
|
// exists the instant prompt() returns. Match broadly since we don't
|
|
// have a stable class name to rely on. Shared by both the strength
|
|
// steppers and the priority field's dialog.
|
|
let attempts = 0;
|
|
const tryFocus = () => {
|
|
attempts++;
|
|
const inputs = document.querySelectorAll("input");
|
|
for (const el of inputs) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (el.readOnly || rect.width === 0 || rect.height === 0 || !document.body.contains(el)) {
|
|
continue;
|
|
}
|
|
el.focus();
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
el.select();
|
|
} catch (e) {
|
|
/* select() can throw on some input types - ignore */
|
|
}
|
|
try {
|
|
el.setSelectionRange(0, el.value.length);
|
|
} catch (e) {
|
|
/* setSelectionRange unsupported on this input type - ignore */
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (attempts < 8) {
|
|
requestAnimationFrame(tryFocus);
|
|
}
|
|
};
|
|
requestAnimationFrame(tryFocus);
|
|
}
|
|
|
|
// Generic "click a readonly number field, get a native quick-edit
|
|
// dialog" helper - shared by the strength steppers and the priority
|
|
// field. rgthree's Power Lora Loader uses LiteGraph's canvas-native
|
|
// prompt() for this exact interaction (confirmed by comparing behavior
|
|
// directly) - it reliably autofocuses+selects its input, unlike the
|
|
// newer Vue-based app.extensionManager.dialog.
|
|
function openValueDialog({ event, label, currentValue, formatValue, applyValue }) {
|
|
const canvas = app?.canvas;
|
|
if (canvas?.prompt) {
|
|
canvas.prompt(
|
|
label,
|
|
formatValue(currentValue()),
|
|
(value) => {
|
|
if (value === null || value === undefined || value === "") return;
|
|
applyValue(value);
|
|
},
|
|
event
|
|
);
|
|
return;
|
|
}
|
|
|
|
const dialogApi = app?.extensionManager?.dialog;
|
|
if (dialogApi?.prompt) {
|
|
dialogApi
|
|
.prompt({
|
|
title: "Value",
|
|
message: label,
|
|
defaultValue: formatValue(currentValue()),
|
|
})
|
|
.then((value) => {
|
|
if (value === null || value === undefined) return;
|
|
applyValue(value);
|
|
});
|
|
focusAndSelectDialogInput();
|
|
return;
|
|
}
|
|
|
|
const raw = window.prompt(label, formatValue(currentValue()));
|
|
if (raw !== null) applyValue(raw);
|
|
}
|
|
|
|
function formatStrength(v) {
|
|
return v.toFixed(2);
|
|
}
|
|
|
|
// Shared factory for a strength stepper control (◀ [value] ▶), bound to
|
|
// whichever entry field it's given. Used once for strengthModel always,
|
|
// and a second time for strengthClip only when clip is actually
|
|
// connected to the node - showing a clip-strength control that does
|
|
// nothing (since Python forces strength_clip to 0 when clip is
|
|
// disconnected) would be confusing rather than helpful.
|
|
function makeStrengthStepper(getValue, setValue, promptLabel) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "fll-strength-wrap";
|
|
|
|
const down = document.createElement("div");
|
|
down.className = "fll-strength-arrow";
|
|
down.textContent = "◀";
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.className = "fll-strength";
|
|
input.step = String(STRENGTH_STEP);
|
|
input.readOnly = true;
|
|
input.value = formatStrength(getValue());
|
|
|
|
const up = document.createElement("div");
|
|
up.className = "fll-strength-arrow";
|
|
up.textContent = "▶";
|
|
|
|
function apply(v) {
|
|
const clamped = isNaN(v) ? 1.0 : v;
|
|
setValue(clamped);
|
|
input.value = formatStrength(clamped);
|
|
onChange();
|
|
}
|
|
|
|
function openDialog(event) {
|
|
openValueDialog({
|
|
event,
|
|
label: promptLabel,
|
|
currentValue: getValue,
|
|
formatValue: formatStrength,
|
|
applyValue: (raw) => apply(parseFloat(raw)),
|
|
});
|
|
}
|
|
|
|
input.onclick = (e) => {
|
|
e.preventDefault();
|
|
input.blur();
|
|
openDialog(e);
|
|
};
|
|
input.onfocus = (e) => {
|
|
input.blur();
|
|
openDialog(e);
|
|
};
|
|
input.onchange = () => {
|
|
apply(parseFloat(input.value));
|
|
};
|
|
down.onclick = () => apply(getValue() - STRENGTH_STEP);
|
|
up.onclick = () => apply(getValue() + STRENGTH_STEP);
|
|
|
|
wrap.appendChild(down);
|
|
wrap.appendChild(input);
|
|
wrap.appendChild(up);
|
|
return wrap;
|
|
}
|
|
|
|
// Ensure both fields exist on the entry even if only one is shown -
|
|
// strengthClip is still stored (e.g. so it's ready if clip gets
|
|
// connected later) even when not currently editable in the UI.
|
|
if (entry.strengthModel === undefined) entry.strengthModel = 1.0;
|
|
if (entry.strengthClip === undefined) entry.strengthClip = entry.strengthModel;
|
|
|
|
const modelStrength = makeStrengthStepper(
|
|
() => entry.strengthModel ?? 1.0,
|
|
(v) => {
|
|
entry.strengthModel = v;
|
|
},
|
|
showClipStrength ? "Model Strength" : "Strength"
|
|
);
|
|
|
|
let clipStrength = null;
|
|
if (showClipStrength) {
|
|
clipStrength = makeStrengthStepper(
|
|
() => entry.strengthClip ?? 1.0,
|
|
(v) => {
|
|
entry.strengthClip = v;
|
|
},
|
|
"Clip Strength"
|
|
);
|
|
}
|
|
|
|
const remove = document.createElement("div");
|
|
remove.className = "fll-remove";
|
|
remove.innerHTML = TRASH_ICON_SVG;
|
|
remove.onclick = () => onRemove();
|
|
|
|
row.appendChild(dragHandle);
|
|
row.appendChild(priority);
|
|
row.appendChild(toggle);
|
|
row.appendChild(name);
|
|
row.appendChild(modelStrength);
|
|
if (clipStrength) row.appendChild(clipStrength);
|
|
row.appendChild(remove);
|
|
|
|
return row;
|
|
}
|
|
|
|
function rowClassName(entry) {
|
|
let cls = "fll-row";
|
|
if (!entry.enabled) cls += " disabled";
|
|
if (entry.enabled) cls += " enabled-highlight";
|
|
if (isLoraMissing(entry.lora)) cls += " missing";
|
|
return cls;
|
|
}
|
|
|
|
const TRASH_ICON_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M4 7h16" stroke-linecap="round"/>
|
|
<path d="M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M6 7l1 13.2A1.8 1.8 0 0 0 8.8 22h6.4a1.8 1.8 0 0 0 1.8-1.8L18 7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M10 11v6M14 11v6" stroke-linecap="round"/>
|
|
</svg>`;
|
|
|
|
app.registerExtension({
|
|
name: "ultimate.lora.loader",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== "UltimateLoraLoader") return;
|
|
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
|
|
|
const node = this;
|
|
|
|
// Double LiteGraph's usual default node width - the standard default
|
|
// (based on title/socket label lengths) is cramped for this node's
|
|
// content (lora names, strength stepper, trash icon all need to fit
|
|
// on one row without truncating too aggressively). Only applied once
|
|
// at creation time, not on every later resize, so it doesn't fight a
|
|
// user's manual width choice on subsequent loads.
|
|
if (node.size && node.size[0]) {
|
|
node.setSize([node.size[0] * 2, node.size[1]]);
|
|
}
|
|
|
|
// ComfyUI auto-generates a visible widget row for the "loras_data"
|
|
// STRING input. It MUST stay in node.widgets - that's where
|
|
// ComfyUI's prompt-serialization step reads widget values from (via
|
|
// widget.serializeValue() / widget.value) to send them to Python -
|
|
// so we can't remove it from node.widgets. But *rendering* it is
|
|
// handled inconsistently across ComfyUI versions: overriding
|
|
// computeSize/draw/mouse worked in some render paths but not the
|
|
// newer Vue-based widget rendering, which still drew a full text row.
|
|
// The one thing that reliably works regardless of internal ComfyUI
|
|
// version is hiding the actual DOM element once it exists - that's a
|
|
// browser-level operation, not something routed through whatever
|
|
// internal widget API ComfyUI happens to use this month.
|
|
const dataWidget = node.widgets?.find((w) => w.name === "loras_data");
|
|
if (dataWidget) {
|
|
// keep these too - harmless, and still helps on canvas-rendered builds
|
|
dataWidget.computeSize = () => [0, 0];
|
|
dataWidget.draw = () => {};
|
|
dataWidget.mouse = () => false;
|
|
}
|
|
|
|
function hideDataWidgetDom() {
|
|
if (!dataWidget) return;
|
|
// Known attribute names ComfyUI has used across versions for a
|
|
// widget's mounted DOM element.
|
|
const el = dataWidget.element || dataWidget.inputEl || dataWidget.textEl || dataWidget.domElement;
|
|
if (el && el.style) {
|
|
el.style.display = "none";
|
|
}
|
|
// Fallback: some ComfyUI versions render each widget row as a DOM
|
|
// node inside the node's own HTML container rather than exposing
|
|
// it directly on the widget object. Find it by matching the
|
|
// widget's current value/name inside this node's DOM subtree.
|
|
if (node.domElement || node.htmlElement) {
|
|
const root = node.domElement || node.htmlElement;
|
|
const rows = root.querySelectorAll?.("[data-widget-name], .comfy-widget, .p-inputtext") || [];
|
|
rows.forEach((rowEl) => {
|
|
const name = rowEl.getAttribute?.("data-widget-name");
|
|
if (name === "loras_data") {
|
|
const rowContainer = rowEl.closest?.(".comfy-widget-row, .widget-row") || rowEl;
|
|
rowContainer.style.display = "none";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Last-resort fallback that doesn't depend on guessing ComfyUI's
|
|
// internal class names at all: the loras_data widget's displayed
|
|
// text is always our JSON array string (starts with "["), which is
|
|
// distinctive enough to find directly. Scoped to this node's own
|
|
// rendered area (container's parent chain) rather than the whole
|
|
// document, so this stays cheap.
|
|
let lorasWidgetObserver = null;
|
|
function findNodeRootElement() {
|
|
// container is appended into ComfyUI's DOM widget wrapper, which
|
|
// itself lives inside this node's rendered element on the canvas.
|
|
// Walk up a few levels from something we know is in the node's
|
|
// subtree to find a stable ancestor to scope the observer to.
|
|
let el = container.parentElement;
|
|
for (let i = 0; i < 5 && el && el.parentElement; i++) {
|
|
el = el.parentElement;
|
|
}
|
|
return el || container.parentElement || document.body;
|
|
}
|
|
function scanAndHideLorasWidget() {
|
|
const root = findNodeRootElement();
|
|
if (!root || !root.querySelectorAll) return;
|
|
const candidates = root.querySelectorAll("input, textarea, div, span");
|
|
candidates.forEach((elm) => {
|
|
if (elm === container || container.contains(elm)) return; // never touch our own UI
|
|
const val = elm.value !== undefined ? elm.value : elm.textContent;
|
|
if (
|
|
typeof val === "string" &&
|
|
val.trim().startsWith("[") &&
|
|
val.includes('"lora"') &&
|
|
val.includes('"enabled"')
|
|
) {
|
|
const rowWrapper = elm.closest?.("label, .comfy-widget-row, .widget-row, li, tr") || elm;
|
|
rowWrapper.style.display = "none";
|
|
}
|
|
});
|
|
}
|
|
function startLorasWidgetObserver() {
|
|
if (lorasWidgetObserver) return;
|
|
const root = findNodeRootElement();
|
|
if (!root) return;
|
|
lorasWidgetObserver = new MutationObserver(() => scanAndHideLorasWidget());
|
|
lorasWidgetObserver.observe(root, { childList: true, subtree: true, characterData: true });
|
|
}
|
|
|
|
let entries = [];
|
|
try {
|
|
entries = dataWidget?.value ? JSON.parse(dataWidget.value) : [];
|
|
} catch (e) {
|
|
entries = [];
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "fll-container";
|
|
container.style.boxSizing = "border-box";
|
|
|
|
const headerRow = document.createElement("div");
|
|
headerRow.className = "fll-header-row";
|
|
|
|
const toggleAllSlot = document.createElement("div");
|
|
toggleAllSlot.className = "fll-header-toggle-slot";
|
|
const toggleAllSwitch = document.createElement("div");
|
|
toggleAllSwitch.className = "fll-toggle-switch";
|
|
const toggleAllKnob = document.createElement("div");
|
|
toggleAllKnob.className = "fll-toggle-knob";
|
|
toggleAllSwitch.appendChild(toggleAllKnob);
|
|
toggleAllSlot.appendChild(toggleAllSwitch);
|
|
|
|
const dragHandleSpacer = document.createElement("div");
|
|
dragHandleSpacer.className = "fll-drag-handle";
|
|
|
|
const priorityLabel = document.createElement("div");
|
|
priorityLabel.className = "fll-header-label";
|
|
priorityLabel.style.flex = "0 0 26px";
|
|
priorityLabel.style.width = "26px";
|
|
priorityLabel.style.textAlign = "center";
|
|
priorityLabel.textContent = "#";
|
|
|
|
const nameLabel = document.createElement("div");
|
|
nameLabel.className = "fll-header-label name-label";
|
|
nameLabel.textContent = "Name";
|
|
|
|
const strengthLabel = document.createElement("div");
|
|
strengthLabel.className = "fll-header-label strength-label";
|
|
strengthLabel.textContent = "Strength";
|
|
|
|
const clipStrengthLabel = document.createElement("div");
|
|
clipStrengthLabel.className = "fll-header-label strength-label";
|
|
clipStrengthLabel.textContent = "Clip";
|
|
clipStrengthLabel.style.display = "none";
|
|
|
|
const deleteAllSlot = document.createElement("div");
|
|
deleteAllSlot.className = "fll-header-remove-slot";
|
|
deleteAllSlot.innerHTML = TRASH_ICON_SVG;
|
|
|
|
headerRow.appendChild(dragHandleSpacer);
|
|
headerRow.appendChild(priorityLabel);
|
|
headerRow.appendChild(toggleAllSlot);
|
|
headerRow.appendChild(nameLabel);
|
|
headerRow.appendChild(strengthLabel);
|
|
headerRow.appendChild(clipStrengthLabel);
|
|
headerRow.appendChild(deleteAllSlot);
|
|
|
|
const rowsWrap = document.createElement("div");
|
|
rowsWrap.style.display = "flex";
|
|
rowsWrap.style.flexDirection = "column";
|
|
rowsWrap.style.gap = "4px";
|
|
rowsWrap.style.boxSizing = "border-box";
|
|
|
|
const addBtn = document.createElement("div");
|
|
addBtn.className = "fll-add-btn";
|
|
addBtn.innerHTML = `<span>Add Lora</span>`;
|
|
|
|
function persist() {
|
|
if (dataWidget) {
|
|
dataWidget.value = JSON.stringify(entries);
|
|
}
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
function isClipConnected() {
|
|
// clip is an optional input now - only show its strength control
|
|
// when something is actually plugged in, since an editable-but-
|
|
// inert control (Python forces strength_clip to 0 when clip is
|
|
// None) would just be confusing UI clutter.
|
|
const clipInput = node.inputs?.find((inp) => inp.name === "clip");
|
|
return !!(clipInput && clipInput.link != null);
|
|
}
|
|
|
|
let draggedIndex = null;
|
|
|
|
function render() {
|
|
rowsWrap.innerHTML = "";
|
|
const showClipStrength = isClipConnected();
|
|
entries.forEach((entry, idx) => {
|
|
const row = makeRow(entry, {
|
|
onChange: persist,
|
|
onRemove: () => {
|
|
entries.splice(idx, 1);
|
|
persist();
|
|
render();
|
|
resizeNode();
|
|
},
|
|
onReplace: (loraPath) => {
|
|
entry.lora = loraPath;
|
|
persist();
|
|
render();
|
|
},
|
|
showClipStrength,
|
|
currentPriority: idx + 1,
|
|
onPriorityChange: (rawPriority) => {
|
|
// Clamp to valid range and convert back to 0-indexed. Typing
|
|
// a priority number is just another way to reorder - same
|
|
// splice-based move as drag-and-drop, just driven by a typed
|
|
// target position instead of a drop location.
|
|
const targetIndex = Math.max(0, Math.min(entries.length - 1, rawPriority - 1));
|
|
if (targetIndex === idx) {
|
|
render(); // snap back to the correct value if unchanged/invalid
|
|
return;
|
|
}
|
|
const [moved] = entries.splice(idx, 1);
|
|
entries.splice(targetIndex, 0, moved);
|
|
persist();
|
|
render();
|
|
},
|
|
onDragStart: (e, rowEl) => {
|
|
draggedIndex = idx;
|
|
rowEl.classList.add("dragging");
|
|
},
|
|
onDragEnd: (e, rowEl) => {
|
|
draggedIndex = null;
|
|
// clear any lingering drop-target indicators on all rows,
|
|
// not just this one, in case the drag ended outside a
|
|
// valid drop target
|
|
rowsWrap.querySelectorAll(".fll-row").forEach((r) => {
|
|
r.classList.remove("dragging", "drop-target-above", "drop-target-below");
|
|
});
|
|
},
|
|
onDragOver: (e, rowEl) => {
|
|
if (draggedIndex === null || draggedIndex === idx) return;
|
|
// show whether the dragged row would land above or below
|
|
// this one, based on cursor position within the row's
|
|
// height - gives clear visual feedback on drop position
|
|
const rect = rowEl.getBoundingClientRect();
|
|
const isAbove = e.clientY - rect.top < rect.height / 2;
|
|
rowEl.classList.toggle("drop-target-above", isAbove);
|
|
rowEl.classList.toggle("drop-target-below", !isAbove);
|
|
},
|
|
onDrop: (e, rowEl) => {
|
|
if (draggedIndex === null || draggedIndex === idx) return;
|
|
const rect = rowEl.getBoundingClientRect();
|
|
const isAbove = e.clientY - rect.top < rect.height / 2;
|
|
let targetIndex = isAbove ? idx : idx + 1;
|
|
// account for the removal shifting indices when the dragged
|
|
// row was originally before the target position
|
|
if (draggedIndex < targetIndex) targetIndex -= 1;
|
|
|
|
const [moved] = entries.splice(draggedIndex, 1);
|
|
entries.splice(targetIndex, 0, moved);
|
|
draggedIndex = null;
|
|
persist();
|
|
render();
|
|
},
|
|
});
|
|
rowsWrap.appendChild(row);
|
|
});
|
|
headerRow.style.display = entries.length > 0 ? "flex" : "none";
|
|
strengthLabel.textContent = showClipStrength ? "Model" : "Strength";
|
|
clipStrengthLabel.style.display = showClipStrength ? "block" : "none";
|
|
// reflect current state: switch shows "on" only when every row is enabled
|
|
const allEnabled = entries.length > 0 && entries.every((e) => e.enabled);
|
|
toggleAllSwitch.className = "fll-toggle-switch" + (allEnabled ? " on" : "");
|
|
}
|
|
|
|
toggleAllSlot.onclick = () => {
|
|
if (entries.length === 0) return;
|
|
// if every entry is already enabled, turn all off; otherwise turn all on
|
|
const allEnabled = entries.every((e) => e.enabled);
|
|
entries.forEach((e) => (e.enabled = !allEnabled));
|
|
persist();
|
|
render();
|
|
};
|
|
|
|
deleteAllSlot.onclick = () => {
|
|
if (entries.length === 0) return;
|
|
entries.length = 0;
|
|
persist();
|
|
render();
|
|
resizeNode();
|
|
};
|
|
|
|
function syncWidth() {
|
|
// Explicitly pin the container's pixel width to the node's current
|
|
// width. Relying on CSS `width: 100%` here is what caused the
|
|
// add-lora bar / text box to bleed past the node's rounded edge -
|
|
// ComfyUI's DOM widget wrapper doesn't reliably constrain percentage
|
|
// widths, so we compute and set an exact pixel value instead.
|
|
//
|
|
// Observed behavior: our content consistently sits closer to the
|
|
// node's left edge than the right, meaning the wrapper ComfyUI's
|
|
// Vue DOM widget system places container into likely insets it
|
|
// from the left by some fixed amount that isn't part of our own
|
|
// box model. A previous attempt shrank container's width from the
|
|
// right to compensate, which was backwards - that makes the right
|
|
// gap bigger, not smaller, since container is left-anchored with
|
|
// no auto margin. Pulling container left with a negative margin
|
|
// directly counteracts a left-side wrapper inset instead.
|
|
const nodeWidth = node.size[0];
|
|
const leftPull = 10; // negative margin to counteract the wrapper's left inset
|
|
const w = Math.max(0, nodeWidth);
|
|
container.style.width = w + "px";
|
|
container.style.maxWidth = w + "px";
|
|
container.style.marginLeft = -leftPull + "px";
|
|
container.style.marginBottom = "0";
|
|
}
|
|
|
|
// Shared height calculation - used both to grow the node when loras
|
|
// are added/removed, and to clamp the node back up (or let the list
|
|
// scroll) if the user tries to manually drag it smaller than the
|
|
// list's natural size.
|
|
const ROW_HEIGHT = 34; // .fll-row rendered height: strength-arrow content (22px) + strength-wrap border (2px) + row padding 4px*2 (8px) + row border 1px*2 (2px)
|
|
const ROW_GAP = 4; // gap between rows in rowsWrap
|
|
const HEADER_HEIGHT = 20; // .fll-header-row: toggle switch (16px) + row padding 2px*2 (4px)
|
|
const ADD_BTN_HEIGHT = 28; // .fll-add-btn: ~14px text line + padding 6px*2 (12px) + border 1px*2 (2px)
|
|
const CONTAINER_PADDING_V = 12; // .fll-container top+bottom padding (6+6)
|
|
const SOCKET_ROWS_HEIGHT = 56; // title bar + model/clip socket rows above the DOM widget
|
|
const GAP_BETWEEN_SECTIONS = 4; // .fll-container's own `gap` between header/rowsWrap/addBtn
|
|
const MIN_VISIBLE_ROWS = 2; // floor for the scrollable list area once the user shrinks the node below its natural size
|
|
const BOTTOM_SLACK = 8; // extra buffer so bottom padding stays visually visible even if real rendered row height is a couple px taller than our ROW_HEIGHT estimate
|
|
|
|
function computeRequiredHeights() {
|
|
const rowCount = entries.length;
|
|
const rowsHeightFull = rowCount > 0 ? rowCount * ROW_HEIGHT + (rowCount - 1) * ROW_GAP : 0;
|
|
const headerHeight = rowCount > 0 ? HEADER_HEIGHT + GAP_BETWEEN_SECTIONS : 0;
|
|
|
|
// "Chrome" = everything in the column except the row list itself:
|
|
// add button, header row (+ its gap), and container's own vertical
|
|
// padding (plus a small slack buffer so the bottom padding doesn't
|
|
// visually disappear if real rendered content is a touch taller
|
|
// than our fixed-constant estimate). Used both to size the node to
|
|
// fit everything (natural), and to figure out how much space is
|
|
// left for rowsWrap once the node is smaller than that (floor /
|
|
// manual resize).
|
|
const chromeHeight =
|
|
ADD_BTN_HEIGHT + headerHeight + (rowCount > 0 ? GAP_BETWEEN_SECTIONS : 0) + CONTAINER_PADDING_V + BOTTOM_SLACK;
|
|
|
|
const minRowsVisible = Math.min(rowCount, MIN_VISIBLE_ROWS);
|
|
const floorRowsHeight = minRowsVisible > 0 ? minRowsVisible * ROW_HEIGHT + (minRowsVisible - 1) * ROW_GAP : 0;
|
|
|
|
return {
|
|
// Node grows to fit every row with no scrolling - the
|
|
// confirmed-working default behavior when adding/removing loras.
|
|
naturalNodeHeight: SOCKET_ROWS_HEIGHT + chromeHeight + rowsHeightFull,
|
|
// Smallest the node is ever allowed to shrink to manually - fits
|
|
// the chrome plus at least MIN_VISIBLE_ROWS rows; beyond that the
|
|
// list scrolls internally instead of spilling out or forcing the
|
|
// node to keep growing.
|
|
floorNodeHeight: SOCKET_ROWS_HEIGHT + chromeHeight + floorRowsHeight,
|
|
chromeHeight,
|
|
};
|
|
}
|
|
|
|
// Guards against onResize's manual-drag clamping logic firing as a
|
|
// side effect of resizeNode()'s own programmatic node.setSize() calls.
|
|
// node.setSize() triggers LiteGraph's onResize regardless of whether
|
|
// a human dragged the corner or we called it ourselves to grow the
|
|
// node - without this guard, every lora add/remove would immediately
|
|
// have its "grow to fit everything, no scrolling" result overwritten
|
|
// by onResize's "clamp to available space, scroll if needed" logic,
|
|
// which is what caused the list to start scrolling again after only
|
|
// 3 rows instead of continuing to grow.
|
|
let isProgrammaticResize = false;
|
|
|
|
function resizeNode() {
|
|
syncWidth();
|
|
// Both previous approaches to sizing this node (manual
|
|
// delta-tracking, then widget.computeSize() reporting a measured
|
|
// height) ended up fighting or looping with whatever height
|
|
// ComfyUI's DOM widget wrapper imposes on `container` - measuring
|
|
// container's height and then having that measurement feed back
|
|
// into constraining container's height again is a trap. To
|
|
// sidestep that failure mode entirely, this doesn't measure
|
|
// anything - it computes the required height arithmetically from
|
|
// known, fixed pixel sizes for each piece of UI, decoupled from
|
|
// whatever the DOM wrapper does with container's actual rendered
|
|
// box.
|
|
const { naturalNodeHeight } = computeRequiredHeights();
|
|
|
|
// Growing to fit every row (no scrolling) as loras are added is
|
|
// the confirmed-working behavior from before - keep it as-is here.
|
|
// rowsWrap only becomes scrollable when the user manually shrinks
|
|
// the node smaller than this natural size (handled in onResize).
|
|
rowsWrap.style.maxHeight = "";
|
|
rowsWrap.style.overflowY = "visible";
|
|
|
|
container.style.minHeight = naturalNodeHeight - SOCKET_ROWS_HEIGHT + "px";
|
|
|
|
if (naturalNodeHeight > node.size[1]) {
|
|
isProgrammaticResize = true;
|
|
node.setSize([node.size[0], naturalNodeHeight]);
|
|
node.setDirtyCanvas(true, true);
|
|
isProgrammaticResize = false;
|
|
}
|
|
}
|
|
|
|
// Keep width in sync whenever the user manually drags-resizes the node,
|
|
// and enforce a minimum height so the row list can never spill out
|
|
// past the node's border. Between the floor and the natural
|
|
// (fits-everything) size, the row list scrolls internally to fit
|
|
// whatever space the user has chosen; below the floor, the node
|
|
// snaps back up to the floor instead of letting content clip.
|
|
const onResize = node.onResize;
|
|
node.onResize = function (size) {
|
|
const r2 = onResize ? onResize.apply(this, arguments) : undefined;
|
|
syncWidth();
|
|
hideDataWidgetDom();
|
|
|
|
// Skip the clamp/scroll logic entirely when this onResize firing
|
|
// is just a side effect of resizeNode()'s own setSize call above -
|
|
// resizeNode() already set the correct (unlimited/natural) rowsWrap
|
|
// state, and we don't want to immediately clamp it back down.
|
|
if (isProgrammaticResize) {
|
|
return r2;
|
|
}
|
|
|
|
const { floorNodeHeight, chromeHeight } = computeRequiredHeights();
|
|
|
|
if (node.size[1] < floorNodeHeight) {
|
|
node.setSize([node.size[0], floorNodeHeight]);
|
|
}
|
|
|
|
// Whatever vertical space remains after the node's fixed chrome
|
|
// (socket rows + button + header + padding) goes to the row list;
|
|
// it scrolls internally to fit within that space.
|
|
const availableForRows = node.size[1] - SOCKET_ROWS_HEIGHT - chromeHeight;
|
|
rowsWrap.style.maxHeight = Math.max(availableForRows, ROW_HEIGHT) + "px";
|
|
rowsWrap.style.overflowY = "auto";
|
|
|
|
node.setDirtyCanvas(true, true);
|
|
return r2;
|
|
};
|
|
|
|
// Re-neutralize the loras_data widget after workflow load/paste, since
|
|
// onConfigure can rebuild widget state from serialized values.
|
|
const onConfigure = node.onConfigure;
|
|
node.onConfigure = function () {
|
|
const r3 = onConfigure ? onConfigure.apply(this, arguments) : undefined;
|
|
const w = node.widgets?.find((w2) => w2.name === "loras_data");
|
|
if (w) {
|
|
w.computeSize = () => [0, 0];
|
|
w.draw = () => {};
|
|
w.mouse = () => false;
|
|
}
|
|
try {
|
|
entries = w?.value ? JSON.parse(w.value) : [];
|
|
} catch (e) {
|
|
entries = [];
|
|
}
|
|
render();
|
|
syncWidth();
|
|
resizeNode();
|
|
hideDataWidgetDom();
|
|
return r3;
|
|
};
|
|
|
|
// Re-render rows whenever clip is connected/disconnected, so the
|
|
// clip-strength stepper appears or disappears immediately rather
|
|
// than only updating on the next unrelated render.
|
|
const onConnectionsChange = node.onConnectionsChange;
|
|
node.onConnectionsChange = function (type, index, connected, link_info, ioSlot) {
|
|
const r4 = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined;
|
|
if (ioSlot?.name === "clip" || node.inputs?.[index]?.name === "clip") {
|
|
render();
|
|
resizeNode();
|
|
}
|
|
return r4;
|
|
};
|
|
|
|
addBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
const rect = addBtn.getBoundingClientRect();
|
|
openLoraBrowser(rect.left, rect.bottom + 4, (loraPath) => {
|
|
entries.push({
|
|
lora: loraPath,
|
|
enabled: true,
|
|
strengthModel: 1.0,
|
|
strengthClip: 1.0,
|
|
});
|
|
persist();
|
|
render();
|
|
resizeNode();
|
|
hideDataWidgetDom();
|
|
});
|
|
};
|
|
|
|
container.appendChild(addBtn);
|
|
container.appendChild(headerRow);
|
|
container.appendChild(rowsWrap);
|
|
|
|
node.addDOMWidget("ultimate_lora_loader_ui", "div", container, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
});
|
|
|
|
render();
|
|
syncWidth();
|
|
|
|
// Fetch the on-disk lora list so missing-file highlighting can light up
|
|
// once we know what actually exists. The paint above happens first and
|
|
// flags nothing (validLoraPaths is null until this resolves); this
|
|
// re-render applies red highlighting to any rows pointing at files that
|
|
// are no longer present.
|
|
fetchLoraTree().then(() => render());
|
|
|
|
// restore size sanity on load (also re-syncs width once ComfyUI has
|
|
// finished laying out the node on the canvas, in case initial
|
|
// node.size wasn't final yet at widget-creation time). Also retry
|
|
// hiding the loras_data DOM element a few times with backoff, since
|
|
// its DOM node may not exist yet at widget-creation time if it's
|
|
// mounted asynchronously (e.g. Vue's next-tick rendering).
|
|
setTimeout(() => {
|
|
syncWidth();
|
|
resizeNode();
|
|
hideDataWidgetDom();
|
|
scanAndHideLorasWidget();
|
|
startLorasWidgetObserver();
|
|
}, 0);
|
|
[50, 150, 400, 1000].forEach((delay) => {
|
|
setTimeout(() => {
|
|
hideDataWidgetDom();
|
|
scanAndHideLorasWidget();
|
|
}, delay);
|
|
});
|
|
|
|
const onRemoved = node.onRemoved;
|
|
node.onRemoved = function () {
|
|
if (lorasWidgetObserver) {
|
|
lorasWidgetObserver.disconnect();
|
|
lorasWidgetObserver = null;
|
|
}
|
|
return onRemoved ? onRemoved.apply(this, arguments) : undefined;
|
|
};
|
|
|
|
return r;
|
|
};
|
|
},
|
|
});
|