Files
KOFiblto-ComfyUI-SaturnNodes/web/js/folder_lora_loader.js
T

1514 lines
66 KiB
JavaScript

import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
// Hash folder name to get a consistent color for the border
function getFolderBorderColor(folderName) {
if (!folderName || folderName === "Root" || folderName === "[ NONE ]" || folderName === "[ RANDOM ]") return null;
let hash = 5381;
for (let i = 0; i < folderName.length; i++) {
hash = ((hash << 5) + hash) + folderName.charCodeAt(i);
}
// Multiply by a large prime to distribute close values (like "1", "2", "3") widely across the 360 hue spectrum
let h = Math.abs(hash * 2246822519) % 360;
// Shift away from green spectrum (80-160) to avoid confusion with the selection highlight
if (h >= 80 && h <= 160) {
h = (h + 100) % 360;
}
return `hsl(${h}, 65%, 50%)`;
}
// Global CSS injection for the visual node styling
const visualStyles = document.createElement("style");
visualStyles.textContent = `
.lora-visual-container.right-aligned-mode .lora-folder-grid,
.lora-visual-container.right-aligned-mode .lora-none-container {
display: flex !important;
flex-wrap: wrap !important;
justify-content: flex-end !important;
gap: 8px !important;
}
.lora-visual-container .lora-folder-grid,
.lora-visual-container .lora-none-container {
display: flex !important;
flex-wrap: wrap !important;
justify-content: flex-start !important;
gap: 8px !important;
}
.lora-visual-container .lora-tile {
width: var(--lora-tile-size, 80px) !important;
flex-shrink: 0 !important;
}
.lora-visual-container {
width: 100%;
display: flex;
flex-direction: column;
background: #111;
border-radius: 8px;
padding: 8px;
box-sizing: border-box;
height: 100%;
min-height: 0;
max-height: 100%;
overflow-y: hidden;
}
.lora-grid-container {
flex: 1 1 0px;
min-height: 0;
max-height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding-right: 4px;
}
.lora-grid-container::-webkit-scrollbar {
width: 6px;
}
.lora-grid-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 4px;
}
.lora-grid-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 4px;
}
.lora-grid-container::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.3);
}
.lora-visual-container.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
}
.lora-visual-container.flex-layout {
display: flex;
flex-direction: column;
}
.lora-none-container {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
margin-bottom: 10px;
}
.lora-control-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 4px 6px;
background: #181818;
border: 1px solid #282828;
border-radius: 5px;
margin-bottom: 8px;
width: 100%;
box-sizing: border-box;
}
.lora-toggle-container {
display: flex;
align-items: center;
gap: 6px;
}
.lora-toggle-label {
font-size: 10px;
color: #888;
font-weight: bold;
margin-right: 4px;
}
.lora-toggle-btn {
background: #252525;
border: 1px solid #353535;
color: #aaa;
font-size: 9px;
font-weight: bold;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.lora-toggle-btn:hover {
background: #303030;
color: #fff;
}
.lora-toggle-btn.active {
background: #007acc;
border-color: #0098ff;
color: #fff;
box-shadow: 0 0 5px rgba(0, 122, 204, 0.4);
}
.lora-folder-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
width: 100%;
}
.lora-folder-header {
display: flex;
align-items: center;
padding: 6px 10px;
background: linear-gradient(90deg, #1d1d1d, #141414);
border: 1px solid #262626;
border-radius: 5px;
cursor: pointer;
user-select: none;
transition: background 0.15s, border-color 0.15s;
}
.lora-folder-header:hover {
background: linear-gradient(90deg, #242424, #1a1a1a);
border-color: #383838;
}
.lora-folder-toggle {
font-size: 9px;
color: #888;
margin-right: 8px;
transition: transform 0.2s ease-in-out;
}
.lora-folder-header.collapsed .lora-folder-toggle {
transform: rotate(-90deg);
}
.lora-folder-name {
font-size: 11px;
font-weight: bold;
color: #d1d1d1;
flex-grow: 1;
}
.lora-folder-count {
font-size: 10px;
color: #555;
margin-right: 10px;
}
.lora-folder-checkbox {
cursor: pointer;
width: 13px;
height: 13px;
accent-color: #007acc;
margin: 0;
}
.lora-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--lora-tile-size, 80px), 1fr));
gap: 8px;
padding: 4px 2px;
}
.lora-folder-container.collapsed .lora-folder-grid {
display: none !important;
}
.lora-tile {
aspect-ratio: 3/4;
background: #1a1a1a;
border: 2px solid #333;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
overflow: hidden;
position: relative;
box-sizing: border-box;
transition: all 0.15s ease-in-out;
}
.lora-tile:hover {
border-color: #007acc;
transform: scale(1.03);
}
.lora-tile.selected {
border-color: #00ff66 !important;
border-width: 4px !important;
box-shadow: 0 0 12px rgba(0, 255, 102, 0.5);
}
.lora-tile img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
}
.lora-tile-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #222, #111);
color: #888;
font-size: 10px;
text-align: center;
padding: 10px;
box-sizing: border-box;
}
.lora-tile-none {
background: linear-gradient(135deg, #a80000, #3a0000) !important;
color: #fff !important;
font-weight: bold;
}
.lora-tile-random {
background: linear-gradient(135deg, #a85500, #3a1a00) !important;
color: #fff !important;
font-weight: bold;
}
.lora-tile-label {
position: absolute;
bottom: 0;
width: 100%;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 10px;
padding: 4px 2px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-top: 1px solid #222;
}
.lora-tile-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 122, 204, 0.85);
color: #fff;
font-size: 8px;
font-weight: bold;
padding: 1px 4px;
border-radius: 8px;
pointer-events: none;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* Top 3 Badges styling */
.lora-tile-badge.rank-1 {
background: linear-gradient(135deg, #ffd700, #ffaa00) !important;
color: #000 !important;
border: 1px solid #fff !important;
box-shadow: 0 0 6px rgba(255, 215, 0, 0.8) !important;
}
.lora-tile-badge.rank-2 {
background: linear-gradient(135deg, #e0e0e0, #9e9e9e) !important;
color: #000 !important;
border: 1px solid #fff !important;
box-shadow: 0 0 6px rgba(224, 224, 224, 0.8) !important;
}
.lora-tile-badge.rank-3 {
background: linear-gradient(135deg, #cd7f32, #8c521f) !important;
color: #fff !important;
border: 1px solid #fff !important;
box-shadow: 0 0 6px rgba(205, 127, 50, 0.8) !important;
}
/* Over 100x Special Effects & Animations */
@keyframes pulseGold100 {
0% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
50% { box-shadow: 0 0 20px #ffaa00, inset 0 0 10px #ffea00; border-color: #ffffff; }
100% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
}
@keyframes pulseSilver100 {
0% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
50% { box-shadow: 0 0 20px #ffffff, inset 0 0 10px #f8fafc; border-color: #ffffff; }
100% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
}
@keyframes pulseBronze100 {
0% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
50% { box-shadow: 0 0 20px #f59e0b, inset 0 0 10px #fbbf24; border-color: #ffedd5; }
100% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
}
@keyframes pulseOver100 {
0% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
50% { box-shadow: 0 0 16px #c084fc; border-color: #e9d5ff; }
100% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
}
.lora-tile.rank-1-100:not(.selected) {
animation: pulseGold100 2s infinite ease-in-out !important;
}
.lora-tile.rank-2-100:not(.selected) {
animation: pulseSilver100 2s infinite ease-in-out !important;
}
.lora-tile.rank-3-100:not(.selected) {
animation: pulseBronze100 2s infinite ease-in-out !important;
}
.lora-tile.over-100:not(.selected) {
animation: pulseOver100 2s infinite ease-in-out !important;
}
.lora-tile-badge.rank-1-100 {
background: linear-gradient(135deg, #fff700, #ff8800) !important;
color: #000 !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #ffd700 !important;
font-weight: 900 !important;
}
.lora-tile-badge.rank-2-100 {
background: linear-gradient(135deg, #ffffff, #64748b) !important;
color: #000 !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #f8fafc !important;
font-weight: 900 !important;
}
.lora-tile-badge.rank-3-100 {
background: linear-gradient(135deg, #f59e0b, #78350f) !important;
color: #fff !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #f59e0b !important;
font-weight: 900 !important;
}
.lora-tile-badge.over-100 {
background: linear-gradient(135deg, #a855f7, #581c87) !important;
color: #fff !important;
border: 1.5px solid #f3e8ff !important;
box-shadow: 0 0 10px #c084fc !important;
font-weight: 900 !important;
}
/* Top 3 Glow styling for non-selected tiles */
.lora-tile.rank-1:not(.selected) {
border-color: #ffd700 !important;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.3) !important;
}
.lora-tile.rank-2:not(.selected) {
border-color: #c0c0c0 !important;
box-shadow: 0 0 8px rgba(192, 192, 192, 0.3) !important;
}
.lora-tile.rank-3:not(.selected) {
border-color: #cd7f32 !important;
box-shadow: 0 0 8px rgba(205, 127, 50, 0.3) !important;
}
`;
document.head.appendChild(visualStyles);
app.registerExtension({
name: "Comfy.FolderLoraLoader",
async nodeCreated(node) {
// --- 1. ORIGINAL DROPDOWN NODES ---
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty" || node.type === "FolderLoraLoader" || node.type === "FolderLoraLoaderPretty") {
const folderWidget = node.widgets.find(w => w.name === "folder");
const loraWidget = node.widgets.find(w => w.name === "lora_name");
let hiddenWidget = node.widgets.find(w => w.name === "_selected_lora");
if (!hiddenWidget) {
hiddenWidget = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
}
hiddenWidget.type = "hidden";
hiddenWidget.hidden = true;
hiddenWidget.computeSize = () => [0, 0];
if (folderWidget && loraWidget) {
let activeRequest = null;
let debounceTimer = null;
const updateLoras = async (forceValue = null) => {
const folder = folderWidget.value || "";
const pretty = node.comfyClass === "FolderLoraLoaderPretty";
const currentRequest = Symbol();
activeRequest = currentRequest;
try {
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=${pretty}`);
if (activeRequest !== currentRequest) return;
const data = await response.json();
let targetValue = forceValue !== null ? forceValue : (hiddenWidget.value || loraWidget.value);
loraWidget.options.values = data.names;
if (data.names.includes(targetValue)) {
loraWidget.value = targetValue;
} else {
let found = false;
for (const [displayName, systemPath] of Object.entries(data.mapping)) {
if (systemPath === targetValue || displayName === targetValue) {
loraWidget.value = displayName;
hiddenWidget.value = displayName;
found = true;
break;
}
}
if (!found) {
loraWidget.value = "[ NONE ]";
hiddenWidget.value = "[ NONE ]";
}
}
node.triggerSlotEvent?.(0);
} catch (e) {
console.error("Failed to fetch filtered LoRAs", e);
}
};
loraWidget.callback = function(value) {
hiddenWidget.value = value;
};
folderWidget.callback = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => updateLoras(), 350);
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
const widgetIndex = node.widgets.indexOf(hiddenWidget);
const savedHiddenValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : hiddenWidget.value;
if (savedHiddenValue) {
hiddenWidget.value = savedHiddenValue;
loraWidget.options.values = [savedHiddenValue, "[ NONE ]"];
loraWidget.value = savedHiddenValue;
setTimeout(() => updateLoras(savedHiddenValue), 100);
}
};
setTimeout(() => {
const initValue = hiddenWidget.value || loraWidget.value;
if (initValue && initValue !== "[ NONE ]") {
loraWidget.options.values = [initValue, "[ NONE ]"];
loraWidget.value = initValue;
}
updateLoras(initValue);
}, 50);
}
}
// --- 2. VISUAL PRETTY PICKER V1 ---
if (node.comfyClass === "FolderLoraLoaderVisualPretty") {
// Force node size to look wide and accommodate the visual picker viewport
node.size = [380, 320];
node.computeSize = function() {
return [380, 320];
};
const folderWidget = node.widgets.find(w => w.name === "folder");
const getHiddenWidget = (name, defaultVal) => {
const widgets = node.widgets.filter(w => w.name === name);
if (widgets.length > 1) {
for (let i = 1; i < widgets.length; i++) {
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
}
}
if (widgets.length === 1) {
widgets[0].hidden = true;
widgets[0].computeSize = () => [0, 0];
return widgets[0];
}
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenWidget = getHiddenWidget("_selected_lora", "[ NONE ]");
// Create Visual HTML DOM Element
const viewContainer = document.createElement("div");
viewContainer.className = "lora-visual-container grid-layout";
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
// Embed DOM Widget inside the node container
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget().value; },
setValue(val) { getHiddenWidget().value = val; },
serialize: false
});
// Detect Vue UI vs LiteGraph by checking for the Vue container class eventually
const isVueUI = !!document.querySelector(".lg-node");
if (isVueUI) {
viewContainer.classList.add("vue-mode");
viewContainer.style.height = "160px";
} else {
viewContainer.classList.add("litegraph-mode");
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
if (viewContainer.style) {
viewContainer.style.height = `${size[1] - 160}px`;
}
};
}
// Adjust sizing constraints dynamically
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 160];
};
// Add Zoom slider widget
const zoomWidget = node.addWidget(
"slider",
"tile_size",
parseInt(initialZoom),
(val) => {
localStorage.setItem("comfy_lora_picker_zoom", val);
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
},
{ min: 50, max: 180, step: 1 }
);
zoomWidget.serialize = false;
let activeRequest = null;
let debounceTimer = null;
const updateVisualGrid = async () => {
const folder = folderWidget.value || "";
const currentRequest = Symbol();
activeRequest = currentRequest;
try {
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true`);
if (activeRequest !== currentRequest) return;
const data = await response.json();
viewContainer.innerHTML = "";
// Calculate Top 3 Ranks by versionless pretty name
const getTop3Ranks = () => {
const prettyCounts = {};
if (data.usage && data.mapping) {
Object.entries(data.mapping).forEach(([displayName, systemPath]) => {
const count = data.usage[systemPath] || 0;
if (count > 0) {
let prettyName = displayName;
if (prettyName.includes(" - ")) {
prettyName = prettyName.split(" - ", 2)[1];
}
prettyName = prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
prettyCounts[prettyName] = Math.max(prettyCounts[prettyName] || 0, count);
}
});
}
const sorted = Object.entries(prettyCounts)
.sort((a, b) => b[1] - a[1]);
const ranks = { rank1: [], rank2: [], rank3: [] };
const uniqueCounts = [...new Set(sorted.map(x => x[1]))];
sorted.forEach(([name, count]) => {
if (count === uniqueCounts[0]) ranks.rank1.push(name);
else if (count === uniqueCounts[1]) ranks.rank2.push(name);
else if (count === uniqueCounts[2]) ranks.rank3.push(name);
});
return ranks;
};
const ranks = getTop3Ranks();
// Helper to create special tiles (NONE and RANDOM)
const createSpecialTile = (name, labelText, borderCol, bgClass) => {
const tile = document.createElement("div");
tile.className = "lora-tile " + bgClass;
tile.dataset.loraName = name;
tile.style.borderColor = borderCol;
tile.dataset.folderColor = borderCol;
const curWidget = getHiddenWidget();
const hiddenVal = curWidget.value;
if (hiddenVal === name) {
tile.className += " selected";
tile.style.borderColor = "#00ff66";
}
const fallback = document.createElement("div");
fallback.className = "lora-tile-fallback " + bgClass;
fallback.innerText = labelText;
tile.appendChild(fallback);
const label = document.createElement("div");
label.className = "lora-tile-label";
label.innerText = labelText;
tile.appendChild(label);
tile.addEventListener("click", () => {
const siblings = viewContainer.querySelectorAll(".lora-tile");
siblings.forEach(s => {
s.classList.remove("selected");
if (s.dataset.folderColor) {
s.style.borderColor = s.dataset.folderColor;
} else {
s.style.borderColor = "";
}
});
tile.classList.add("selected");
tile.style.borderColor = "#00ff66";
const w = getHiddenWidget();
w.value = name;
if (w.callback) w.callback(name);
node.triggerSlotEvent?.(0);
});
viewContainer.appendChild(tile);
};
// NONE and RANDOM tiles at the top
createSpecialTile("[ NONE ]", "NONE", "#ff3333", "lora-tile-none");
createSpecialTile("[ RANDOM ]", "RANDOM", "#ffaa00", "lora-tile-random");
data.names.forEach(loraName => {
if (loraName === "[ NONE ]" || loraName === "[ RANDOM ]") return;
const tile = document.createElement("div");
tile.className = "lora-tile";
tile.dataset.loraName = loraName;
const subFolder = loraName.includes(" - ") ? loraName.split(" - ")[0] : "";
const borderCol = getFolderBorderColor(subFolder);
if (borderCol) {
tile.style.borderColor = borderCol;
tile.dataset.folderColor = borderCol;
}
const curWidget = getHiddenWidget();
if (curWidget.value === loraName) {
tile.className += " selected";
tile.style.borderColor = "#00ff66";
}
const img = document.createElement("img");
img.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true`;
img.onerror = () => {
img.remove();
const fallback = document.createElement("div");
fallback.className = "lora-tile-fallback";
fallback.innerText = loraName;
tile.prepend(fallback);
};
tile.appendChild(img);
// Rank classification based on versionless pretty name
let tilePrettyName = loraName;
if (tilePrettyName.includes(" - ")) {
tilePrettyName = tilePrettyName.split(" - ", 2)[1];
}
tilePrettyName = tilePrettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
const systemPath = data.mapping[loraName];
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
const isOver100 = usageCount >= 100;
let rankClass = "";
if (ranks.rank1.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-1-100" : "rank-1";
} else if (ranks.rank2.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-2-100" : "rank-2";
} else if (ranks.rank3.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-3-100" : "rank-3";
} else if (isOver100) {
rankClass = "over-100";
}
if (rankClass) {
tile.classList.add(rankClass);
}
if (usageCount > 0) {
const badge = document.createElement("div");
badge.className = "lora-tile-badge";
if (rankClass) badge.classList.add(rankClass);
badge.innerText = isOver100 ? `🔥 ${usageCount}` : usageCount;
tile.appendChild(badge);
}
// Add bottom caption label
const label = document.createElement("div");
label.className = "lora-tile-label";
label.innerText = loraName;
tile.appendChild(label);
// Trigger click selection
tile.addEventListener("click", () => {
const siblings = viewContainer.querySelectorAll(".lora-tile");
siblings.forEach(s => {
s.classList.remove("selected");
if (s.dataset.folderColor) {
s.style.borderColor = s.dataset.folderColor;
} else {
s.style.borderColor = "";
}
});
tile.classList.add("selected");
tile.style.borderColor = "#00ff66";
const w = getHiddenWidget();
w.value = loraName;
if (w.callback) w.callback(loraName);
node.triggerSlotEvent?.(0);
});
viewContainer.appendChild(tile);
});
} catch (e) {
console.error("Failed to build visual picker", e);
}
};
folderWidget.callback = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
};
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
const curWidget = getHiddenWidget();
const widgetIndex = node.widgets.indexOf(curWidget);
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
if (savedValue) {
curWidget.value = savedValue;
setTimeout(() => updateVisualGrid(), 100);
}
};
setTimeout(() => updateVisualGrid(), 100);
}
// --- 3. VISUAL PRETTY PICKER V2 (Collapsible folders & Multi-selection) ---
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
node.size = [380, 360];
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
const viewContainer = document.querySelector(".lora-visual-container");
}
return [node.size[0], Math.max(360, node.size[1])];
};
const folderWidget = node.widgets.find(w => w.name === "folder");
const getHiddenWidget = () => {
const widgets = node.widgets.filter(w => w.name === "_selected_lora");
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]" && w.value !== "[]") || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_selected_lora", "[]", () => {}, { serialize: true });
w.type = "hidden";
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenWidget = getHiddenWidget();
const getHiddenModeWidget = () => {
const widgets = node.widgets.filter(w => w.name === "_selection_mode");
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && w.value !== "All") || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_selection_mode" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_selection_mode", "Sequential", () => {}, { serialize: true });
w.type = "hidden";
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenModeWidget = getHiddenModeWidget();
const getHiddenScrapeWidget = () => {
const widgets = node.widgets.filter(w => w.name === "_scrape_on_new");
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && (w.value === "true" || w.value === "false")) || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_scrape_on_new" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_scrape_on_new", "true", () => {}, { serialize: true });
w.type = "hidden";
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenScrapeWidget = getHiddenScrapeWidget();
const viewContainer = document.createElement("div");
viewContainer.className = "lora-visual-container flex-layout";
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
const gridContainer = document.createElement("div");
gridContainer.className = "lora-grid-container";
// Isolate mouse wheel scroll events from triggering canvas zooming/panning
viewContainer.addEventListener("wheel", (e) => {
e.stopPropagation();
});
let userSavedHeight = 420;
const applyDisplayMode = (mode) => {
const isShowAll = mode === "Show All";
if (isShowAll) {
viewContainer.style.setProperty("height", "auto", "important");
viewContainer.style.setProperty("max-height", "none", "important");
viewContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("height", "auto", "important");
gridContainer.style.setProperty("max-height", "none", "important");
gridContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("flex", "none", "important");
setTimeout(() => {
const contentH = gridContainer.scrollHeight || 0;
const neededHeight = contentH + 160;
node.setSize([node.size[0], Math.max(360, neededHeight)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}, 50);
} else {
viewContainer.style.removeProperty("height");
viewContainer.style.removeProperty("max-height");
viewContainer.style.setProperty("overflow-y", "hidden", "important");
gridContainer.style.removeProperty("height");
gridContainer.style.removeProperty("max-height");
gridContainer.style.setProperty("overflow-y", "auto", "important");
gridContainer.style.setProperty("flex", "1 1 0px", "important");
const restoredH = node.userCustomHeight || userSavedHeight || 420;
if (viewContainer.style) {
viewContainer.style.height = `${restoredH - 80}px`;
}
node.setSize([node.size[0], Math.max(360, restoredH)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
};
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(360, node.size[1])];
};
const updateNodeSize = () => {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
applyDisplayMode(mode);
};
// --- Control Bar for Mode Selector ---
const controlBar = document.createElement("div");
controlBar.className = "lora-control-bar";
const toggleContainer = document.createElement("div");
toggleContainer.className = "lora-toggle-container";
const toggleLabel = document.createElement("span");
toggleLabel.className = "lora-toggle-label";
toggleLabel.innerText = "Mode:";
toggleContainer.appendChild(toggleLabel);
const modeSelect = document.createElement("select");
modeSelect.className = "lora-mode-select";
modeSelect.style.background = "#252525";
modeSelect.style.border = "1px solid #353535";
modeSelect.style.color = "#fff";
modeSelect.style.fontSize = "10px";
modeSelect.style.fontWeight = "bold";
modeSelect.style.padding = "2px 4px";
modeSelect.style.borderRadius = "4px";
modeSelect.style.cursor = "pointer";
const modes = [
{ value: "All", label: "All Loras" },
{ value: "Random", label: "Random (With Dupes)" },
{ value: "Sequential", label: "Sequential Cycle" },
{ value: "Random (No Replace)", label: "Random (No Dupes)" }
];
modes.forEach(m => {
const opt = document.createElement("option");
opt.value = m.value;
opt.innerText = m.label;
modeSelect.appendChild(opt);
});
const syncModeToggleUI = () => {
const curModeWidget = getHiddenModeWidget();
if (curModeWidget.value && modes.some(m => m.value === curModeWidget.value)) {
modeSelect.value = curModeWidget.value;
} else {
modeSelect.value = "Sequential";
}
};
modeSelect.addEventListener("change", (e) => {
getHiddenModeWidget().value = e.target.value;
syncModeToggleUI();
node.triggerSlotEvent?.(0);
});
toggleContainer.appendChild(modeSelect);
controlBar.appendChild(toggleContainer);
// Scrape Toggle Container
const scrapeToggleContainer = document.createElement("div");
scrapeToggleContainer.className = "lora-toggle-container";
const scrapeLabel = document.createElement("span");
scrapeLabel.className = "lora-toggle-label";
scrapeLabel.innerText = "Scrape:";
scrapeToggleContainer.appendChild(scrapeLabel);
const scrapeOnBtn = document.createElement("button");
scrapeOnBtn.className = "lora-toggle-btn active";
scrapeOnBtn.innerText = "ON";
const scrapeOffBtn = document.createElement("button");
scrapeOffBtn.className = "lora-toggle-btn";
scrapeOffBtn.innerText = "OFF";
const syncScrapeToggleUI = () => {
const curScrapeWidget = getHiddenScrapeWidget();
if (curScrapeWidget.value === "false") {
scrapeOffBtn.classList.add("active");
scrapeOnBtn.classList.remove("active");
} else {
scrapeOnBtn.classList.add("active");
scrapeOffBtn.classList.remove("active");
}
};
scrapeOnBtn.addEventListener("click", () => {
getHiddenScrapeWidget().value = "true";
syncScrapeToggleUI();
updateVisualGrid();
});
scrapeOffBtn.addEventListener("click", () => {
getHiddenScrapeWidget().value = "false";
syncScrapeToggleUI();
updateVisualGrid();
});
scrapeToggleContainer.appendChild(scrapeOnBtn);
scrapeToggleContainer.appendChild(scrapeOffBtn);
controlBar.appendChild(scrapeToggleContainer);
viewContainer.appendChild(controlBar);
viewContainer.appendChild(gridContainer);
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget().value; },
setValue(val) { getHiddenWidget().value = val; },
serialize: false
});
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 190];
};
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
if (!isShowAll && size) {
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
viewContainer.style.height = `${size[1] - 80}px`;
}
}
};
const zoomWidget = node.addWidget(
"slider",
"tile_size",
parseInt(initialZoom),
(val) => {
localStorage.setItem("comfy_lora_picker_zoom", val);
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
},
{ min: 50, max: 180, step: 1 }
);
zoomWidget.serialize = false;
let activeRequest = null;
let debounceTimer = null;
let foldersMap = {};
const getSelectedLoras = () => {
const curWidget = getHiddenWidget();
let currentSelected = [];
try {
currentSelected = JSON.parse(curWidget.value || "[]");
if (!Array.isArray(currentSelected)) {
currentSelected = [curWidget.value];
}
} catch (e) {
if (curWidget.value && curWidget.value !== "[ NONE ]" && curWidget.value !== "[]") {
currentSelected = [curWidget.value];
} else {
currentSelected = [];
}
}
// Filter out "[ NONE ]"
currentSelected = currentSelected.filter(n => n !== "[ NONE ]" && n);
return currentSelected;
};
const updateFolderCheckboxes = () => {
const selectedList = getSelectedLoras();
Object.keys(foldersMap).forEach(folder => {
const headerEl = viewContainer.querySelector(`.lora-folder-header[data-folder="${CSS.escape(folder)}"]`);
if (!headerEl) return;
const checkbox = headerEl.querySelector(".lora-folder-checkbox");
if (!checkbox) return;
const items = foldersMap[folder];
if (!items || items.length === 0) return;
const selectedCount = items.filter(item => selectedList.includes(item.loraName)).length;
if (selectedCount === items.length) {
checkbox.checked = true;
checkbox.indeterminate = false;
} else if (selectedCount > 0) {
checkbox.checked = false;
checkbox.indeterminate = true;
} else {
checkbox.checked = false;
checkbox.indeterminate = false;
}
});
};
const updateSelection = (selectedList) => {
// Filter out "[ NONE ]"
selectedList = selectedList.filter(n => n !== "[ NONE ]" && n);
const curWidget = getHiddenWidget();
curWidget.value = JSON.stringify(selectedList);
const tiles = viewContainer.querySelectorAll(".lora-tile");
tiles.forEach(t => {
const name = t.dataset.loraName;
if (selectedList.includes(name)) {
t.classList.add("selected");
t.style.borderColor = "#00ff66";
} else {
t.classList.remove("selected");
if (t.dataset.folderColor) {
t.style.borderColor = t.dataset.folderColor;
} else {
t.style.borderColor = "";
}
}
});
updateFolderCheckboxes();
node.triggerSlotEvent?.(0);
};
const updateVisualGrid = async () => {
const folder = folderWidget.value || "";
const scrapeOnNew = getHiddenScrapeWidget().value;
const currentRequest = Symbol();
activeRequest = currentRequest;
try {
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true&scrape_on_new=${encodeURIComponent(scrapeOnNew)}`);
if (activeRequest !== currentRequest) return;
const data = await response.json();
gridContainer.innerHTML = "";
foldersMap = {};
foldersMap["Root"] = [];
const selectedList = getSelectedLoras();
// Calculate Top 3 Ranks by versionless pretty name
const getTop3Ranks = () => {
const prettyCounts = {};
if (data.usage && data.mapping) {
Object.entries(data.mapping).forEach(([displayName, systemPath]) => {
const count = data.usage[systemPath] || 0;
if (count > 0) {
let prettyName = displayName;
if (prettyName.includes(" - ")) {
prettyName = prettyName.split(" - ", 2)[1];
}
prettyName = prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
prettyCounts[prettyName] = Math.max(prettyCounts[prettyName] || 0, count);
}
});
}
const sorted = Object.entries(prettyCounts)
.sort((a, b) => b[1] - a[1]);
const ranks = { rank1: [], rank2: [], rank3: [] };
const uniqueCounts = [...new Set(sorted.map(x => x[1]))];
sorted.forEach(([name, count]) => {
if (count === uniqueCounts[0]) ranks.rank1.push(name);
else if (count === uniqueCounts[1]) ranks.rank2.push(name);
else if (count === uniqueCounts[2]) ranks.rank3.push(name);
});
return ranks;
};
const ranks = getTop3Ranks();
// --- Group items by folder ---
const mtimeMap = data.mtime || {};
data.names.forEach(loraName => {
if (loraName === "[ NONE ]") return;
const parts = loraName.split(" - ");
const subFolder = parts.length > 1 ? parts[0] : "Root";
const prettyName = parts.length > 1 ? parts.slice(1).join(" - ") : loraName;
const systemPath = data.mapping[loraName];
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
if (!foldersMap[subFolder]) {
foldersMap[subFolder] = [];
}
foldersMap[subFolder].push({ loraName, prettyName, usageCount, systemPath });
});
// Read sorting widgets
const getWidgetVal = (wName, defaultVal) => {
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
return w ? w.value : defaultVal;
};
const sortLorasMode = getWidgetVal("sort_loras_by", "Name (A-Z)");
const sortFoldersMode = getWidgetVal("sort_folders_by", "Name (A-Z)");
const folderPosMode = getWidgetVal("folder_position", "Folders First");
const contentAlignMode = getWidgetVal("content_alignment", "Left Aligned");
if (contentAlignMode === "Right Aligned") {
viewContainer.classList.add("right-aligned-mode");
} else {
viewContainer.classList.remove("right-aligned-mode");
}
// Sort individual LoRAs inside each folder
Object.keys(foldersMap).forEach(subFolder => {
foldersMap[subFolder].sort((a, b) => {
if (sortLorasMode === "Name (Z-A)") {
return b.prettyName.localeCompare(a.prettyName);
} else if (sortLorasMode === "Usage (High to Low)") {
return (b.usageCount || 0) - (a.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Usage (Low to High)") {
return (a.usageCount || 0) - (b.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Date Modified (Newest First)") {
const timeA = mtimeMap[a.systemPath] || 0;
const timeB = mtimeMap[b.systemPath] || 0;
return timeB - timeA || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Date Modified (Oldest First)") {
const timeA = mtimeMap[a.systemPath] || 0;
const timeB = mtimeMap[b.systemPath] || 0;
return timeA - timeB || a.prettyName.localeCompare(b.prettyName);
} else { // "Name (A-Z)" default
return a.prettyName.localeCompare(b.prettyName);
}
});
});
// Sort folders (excluding Root for position ordering)
const nonRootFolders = Object.keys(foldersMap).filter(f => f !== "Root").sort((a, b) => {
const itemsA = foldersMap[a] || [];
const itemsB = foldersMap[b] || [];
const totalA = itemsA.reduce((sum, i) => sum + (i.usageCount || 0), 0);
const totalB = itemsB.reduce((sum, i) => sum + (i.usageCount || 0), 0);
const avgA = itemsA.length > 0 ? totalA / itemsA.length : 0;
const avgB = itemsB.length > 0 ? totalB / itemsB.length : 0;
if (sortFoldersMode === "Name (Z-A)") {
return b.localeCompare(a);
} else if (sortFoldersMode === "Total Usage (High to Low)") {
return totalB - totalA || a.localeCompare(b);
} else if (sortFoldersMode === "Average Usage (High to Low)") {
return avgB - avgA || a.localeCompare(b);
} else if (sortFoldersMode === "Total LoRAs (Most First)") {
return itemsB.length - itemsA.length || a.localeCompare(b);
} else { // "Name (A-Z)" default
return a.localeCompare(b);
}
});
// Combine folder keys based on folder_position
let sortedFolders = [];
const hasRoot = foldersMap["Root"] && foldersMap["Root"].length > 0;
if (folderPosMode === "Root LoRAs First") {
if (hasRoot) sortedFolders.push("Root");
sortedFolders.push(...nonRootFolders);
} else {
sortedFolders.push(...nonRootFolders);
if (hasRoot) sortedFolders.push("Root");
}
// Atomic DOM Fragment construction for maximum performance
const fragment = document.createDocumentFragment();
// Setup IntersectionObserver for high-performance lazy image decoding
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
observer.unobserve(img);
}
});
}, { root: gridContainer, rootMargin: "150px" });
// --- Render folder groups ---
sortedFolders.forEach(subFolder => {
const items = foldersMap[subFolder];
if (items.length === 0) return;
const folderContainer = document.createElement("div");
folderContainer.className = "lora-folder-container";
const isCollapsed = localStorage.getItem(`comfy_lora_picker_folder_collapsed_${subFolder}`) === "true";
if (isCollapsed) {
folderContainer.classList.add("collapsed");
}
// Folder Header
const header = document.createElement("div");
header.className = "lora-folder-header";
header.dataset.folder = subFolder;
if (isCollapsed) {
header.classList.add("collapsed");
}
const toggle = document.createElement("span");
toggle.className = "lora-folder-toggle";
toggle.innerText = "▼";
header.appendChild(toggle);
const nameSpan = document.createElement("span");
nameSpan.className = "lora-folder-name";
nameSpan.innerText = subFolder;
header.appendChild(nameSpan);
const countSpan = document.createElement("span");
countSpan.className = "lora-folder-count";
countSpan.innerText = `(${items.length})`;
header.appendChild(countSpan);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "lora-folder-checkbox";
checkbox.title = "Select/Deselect all in folder";
checkbox.addEventListener("click", (e) => {
e.stopPropagation();
const isChecked = checkbox.checked;
let currentSelected = getSelectedLoras();
items.forEach(item => {
if (isChecked) {
if (!currentSelected.includes(item.loraName)) {
currentSelected.push(item.loraName);
}
} else {
currentSelected = currentSelected.filter(n => n !== item.loraName);
}
});
updateSelection(currentSelected);
});
header.appendChild(checkbox);
header.addEventListener("click", () => {
const collapsed = folderContainer.classList.toggle("collapsed");
header.classList.toggle("collapsed", collapsed);
localStorage.setItem(`comfy_lora_picker_folder_collapsed_${subFolder}`, collapsed ? "true" : "false");
});
folderContainer.appendChild(header);
// Grid containing items of this folder
const grid = document.createElement("div");
grid.className = "lora-folder-grid";
items.forEach(item => {
const tile = document.createElement("div");
tile.className = "lora-tile";
tile.dataset.loraName = item.loraName;
const borderCol = getFolderBorderColor(subFolder);
if (borderCol) {
tile.style.borderColor = borderCol;
tile.dataset.folderColor = borderCol;
}
if (selectedList.includes(item.loraName)) {
tile.className += " selected";
tile.style.borderColor = "#00ff66";
}
const img = document.createElement("img");
img.loading = "lazy";
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
imageObserver.observe(img);
img.onerror = () => {
img.remove();
const fallback = document.createElement("div");
fallback.className = "lora-tile-fallback";
fallback.innerText = item.prettyName;
tile.prepend(fallback);
};
tile.appendChild(img);
let tilePrettyName = item.prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
const isOver100 = item.usageCount >= 100;
let rankClass = "";
if (ranks.rank1.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-1-100" : "rank-1";
} else if (ranks.rank2.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-2-100" : "rank-2";
} else if (ranks.rank3.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-3-100" : "rank-3";
} else if (isOver100) {
rankClass = "over-100";
}
if (rankClass) {
tile.classList.add(rankClass);
}
if (item.usageCount > 0) {
const badge = document.createElement("div");
badge.className = "lora-tile-badge";
if (rankClass) badge.classList.add(rankClass);
badge.innerText = isOver100 ? `🔥 ${item.usageCount}` : item.usageCount;
tile.appendChild(badge);
}
const label = document.createElement("div");
label.className = "lora-tile-label";
label.innerText = item.prettyName;
tile.appendChild(label);
tile.addEventListener("click", (e) => {
let currentSelected = getSelectedLoras();
if (e.ctrlKey || e.metaKey) {
if (currentSelected.includes(item.loraName)) {
currentSelected = currentSelected.filter(n => n !== item.loraName);
} else {
currentSelected.push(item.loraName);
}
} else {
if (currentSelected.length === 1 && currentSelected[0] === item.loraName) {
currentSelected = [];
} else {
currentSelected = [item.loraName];
}
}
updateSelection(currentSelected);
});
grid.appendChild(tile);
});
folderContainer.appendChild(grid);
fragment.appendChild(folderContainer);
});
// Append entire DocumentFragment to gridContainer
gridContainer.appendChild(fragment);
// Set initial checkboxes state
updateFolderCheckboxes();
setTimeout(() => updateNodeSize(), 50);
} catch (e) {
console.error("Failed to build visual picker v2", e);
}
};
folderWidget.callback = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
};
["sort_loras_by", "sort_folders_by", "folder_position", "content_alignment"].forEach(wName => {
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
if (w) {
const origCb = w.callback;
w.callback = function() {
if (origCb) origCb.apply(this, arguments);
updateVisualGrid();
};
}
});
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
const curWidget = getHiddenWidget();
const widgetIndex = node.widgets.indexOf(curWidget);
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
if (savedValue) {
curWidget.value = savedValue;
}
const curModeWidget = getHiddenModeWidget();
const modeIndex = node.widgets.indexOf(curModeWidget);
const savedMode = (config.widgets_values && modeIndex !== -1) ? config.widgets_values[modeIndex] : curModeWidget.value;
if (savedMode) {
curModeWidget.value = savedMode;
}
const curScrapeWidget = getHiddenScrapeWidget();
const scrapeIndex = node.widgets.indexOf(curScrapeWidget);
const savedScrape = (config.widgets_values && scrapeIndex !== -1) ? config.widgets_values[scrapeIndex] : curScrapeWidget.value;
if (savedScrape) {
curScrapeWidget.value = savedScrape;
}
syncModeToggleUI();
syncScrapeToggleUI();
setTimeout(() => updateVisualGrid(), 100);
};
syncModeToggleUI();
syncScrapeToggleUI();
setTimeout(() => {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget) {
const origCallback = displayModeWidget.callback;
displayModeWidget.callback = function(val) {
if (origCallback) origCallback.apply(this, arguments);
applyDisplayMode(val || displayModeWidget.value);
};
applyDisplayMode(displayModeWidget.value || "Scrollable");
}
updateVisualGrid();
}, 100);
}
}
});