Files
JasonHoku-ComfyUI-Ultimate-…/web/conf_builder/conf-builder-ui-components.js
T
Jason HokuandClaude Opus 4.6 7d5fbb213d perf: virtualize searchable select dropdown, fix event listener leak
- Limit dropdown to first 50 options (was creating ALL options as DOM
  nodes — 500+ for LoRAs, 100+ for models — on every focus/keystroke)
- Show "... N more — type to filter" message when truncated
- Use event delegation (single onmouseover/onmouseout/onclick on parent)
  instead of per-option event handlers
- Replace document.addEventListener("click") with input.onblur to close
  dropdown (was leaking global click listeners that accumulated on every
  re-render — 50+ listeners after a few refreshes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:06:10 -07:00

962 lines
37 KiB
JavaScript

/**
* Config Builder UI Components Module
* Reusable UI elements and component builders
*/
import { normalizePath } from './conf-builder-utilities.js';
// --- SEARCHABLE SELECT COMPONENT ---
export function createSearchableSelect(options, currentValue, onChange, placeholder = "Search...") {
const MAX_VISIBLE = 50; // Only render first N items for performance
const container = document.createElement("div");
container.style.position = "relative";
container.style.width = "100%";
const input = document.createElement("input");
input.className = "cb-input";
input.type = "text";
input.placeholder = placeholder;
input.value = normalizePath(currentValue) || "";
input.autocomplete = "off";
const dropdown = document.createElement("div");
dropdown.style.cssText = `
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 300px;
overflow-y: auto;
background: #1a1a1a;
border: 1px solid #0066cc;
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
display: none;
`;
let filteredOptions = options;
const renderOptions = () => {
dropdown.textContent = "";
const showCount = Math.min(filteredOptions.length, MAX_VISIBLE);
for (let i = 0; i < showCount; i++) {
const optDiv = document.createElement("div");
optDiv.textContent = filteredOptions[i];
optDiv.dataset.idx = i;
optDiv.style.cssText = "padding: 8px 10px; cursor: pointer; color: white; font-size: 13px; font-family: monospace;";
dropdown.appendChild(optDiv);
}
if (filteredOptions.length > MAX_VISIBLE) {
const more = document.createElement("div");
more.textContent = `... ${filteredOptions.length - MAX_VISIBLE} more \u2014 type to filter`;
more.style.cssText = "padding: 6px 10px; color: #666; font-size: 11px; font-style: italic;";
dropdown.appendChild(more);
}
if (filteredOptions.length === 0) {
const noResults = document.createElement("div");
noResults.textContent = "No matches found";
noResults.style.cssText = "padding: 8px 10px; color: #666; font-style: italic;";
dropdown.appendChild(noResults);
}
};
// Event delegation for dropdown clicks and hover (instead of per-option handlers)
dropdown.onmouseover = (e) => {
const t = e.target;
if (t.dataset && t.dataset.idx !== undefined) t.style.background = "#2a2a2a";
};
dropdown.onmouseout = (e) => {
const t = e.target;
if (t.dataset && t.dataset.idx !== undefined) t.style.background = "transparent";
};
dropdown.onclick = (e) => {
const t = e.target;
if (t.dataset && t.dataset.idx !== undefined) {
const opt = filteredOptions[parseInt(t.dataset.idx)];
if (opt !== undefined) {
input.value = opt;
dropdown.style.display = "none";
onChange(opt);
}
}
};
input.onfocus = () => {
filteredOptions = options;
renderOptions();
dropdown.style.display = "block";
};
input.oninput = () => {
const searchTerm = input.value.toLowerCase();
filteredOptions = options.filter(opt =>
opt.toLowerCase().includes(searchTerm)
);
renderOptions();
dropdown.style.display = "block";
};
input.onkeydown = (e) => {
if (e.key === "Enter" && filteredOptions.length > 0) {
input.value = filteredOptions[0];
dropdown.style.display = "none";
onChange(filteredOptions[0]);
} else if (e.key === "Escape") {
dropdown.style.display = "none";
}
};
// Close dropdown when clicking outside — use onblur instead of global listener
input.onblur = () => {
// Delay to allow dropdown click to register before hiding
setTimeout(() => { dropdown.style.display = "none"; }, 200);
};
container.appendChild(input);
container.appendChild(dropdown);
return container;
}
// --- SLIDER COMPONENT ---
export function createSlider(label, value, min, max, step, onChange) {
const container = document.createElement("div");
container.className = "cb-slider-container";
const labelElem = document.createElement("span");
labelElem.className = "cb-label";
labelElem.textContent = label;
labelElem.style.flex = "1";
container.appendChild(labelElem);
const slider = document.createElement("input");
slider.type = "range";
slider.className = "cb-slider";
slider.min = min;
slider.max = max;
slider.step = step;
slider.value = value;
container.appendChild(slider);
// Number Input for better control
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.className = "cb-number-input";
// FIX 1: Set step to "any" to allow any decimal precision without browser validation errors
numberInput.step = "any";
// FIX 2: Do NOT set min/max here, so the browser allows typing outside the range
numberInput.value = value.toFixed(2);
container.appendChild(numberInput);
// Sync Logic: Slider -> Number Input
slider.oninput = () => {
const val = parseFloat(slider.value);
numberInput.value = val.toFixed(2);
onChange(val);
};
// Sync Logic: Number Input -> Slider
numberInput.onchange = () => {
let val = parseFloat(numberInput.value);
// Safety check for empty or invalid input
if (isNaN(val)) {
val = parseFloat(slider.value);
}
slider.value = val;
// Optional: formatting to 2 decimals, or remove to keep exact typed value
numberInput.value = val;
onChange(val);
};
return container;
}
// --- INPUT GROUP COMPONENT ---
export function createInputGroup(label, element) {
const group = document.createElement("div");
group.className = "cb-input-group";
const labelElem = document.createElement("label");
labelElem.className = "cb-label";
labelElem.textContent = label;
group.appendChild(labelElem);
group.appendChild(element);
return group;
}
// --- STYLES ---
export function getStyles() {
return `
<style>
.cb-container { display: flex; flex-direction: column; height: 100%; overflow: hidden; box-sizing: border-box; }
/* Top Bar */
.cb-top-bar { display: flex; align-items: center; gap: 12px; padding: 6px 12px; background: #1a1a1a; border-bottom: 1px solid #3a3a3a; position: sticky; top: 0; z-index: 100; min-height: 36px; flex-shrink: 0; }
.cb-top-bar-input { flex: 1; background: transparent; border: 1px solid transparent; color: #e0e0e0; font-size: 14px; font-weight: bold; padding: 6px 10px; border-radius: 4px; font-family: monospace; min-width: 100px; }
.cb-top-bar-input:hover { border-color: #444; }
.cb-top-bar-input:focus { outline: none; border-color: #0077dd; background: #1a1a1a; }
.cb-settings-btn { background: #333; border: 1px solid #555; color: #ccc; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.cb-settings-btn:hover { background: #444; color: #fff; }
/* Settings Dropdown */
.cb-settings-dropdown { position: absolute; top: 100%; right: 0; min-width: 280px; background: #1e1e1e; border: 1px solid #444; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 200; overflow: hidden; display: none; max-height: 80vh; overflow-y: auto; }
.cb-settings-dropdown.open { display: block; }
.cb-settings-item { display: flex; align-items: center; gap: 8px; padding: 8px 14px; cursor: pointer; color: #ccc; font-size: 12px; border: none; background: none; width: 100%; text-align: left; box-sizing: border-box; }
.cb-settings-item:hover { background: #2a2a2a; color: #fff; }
.cb-settings-divider { height: 1px; background: #333; margin: 4px 0; }
.cb-settings-expand { padding: 8px 14px; background: #252525; border-top: 1px solid #333; display: none; }
.cb-settings-expand.open { display: block; }
/* Sidebar */
.cb-layout-wrapper { display: flex; flex: 1; overflow: hidden; }
.cb-sidebar { width: 42px; min-width: 42px; background: #1e1e1e; border-right: 1px solid #333; display: flex; flex-direction: column; align-items: center; padding-top: 8px; gap: 2px; flex-shrink: 0; overflow-y: auto; }
.cb-sidebar-icon { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 6px; cursor: pointer; font-size: 16px; background: transparent; border: none; color: #888; position: relative; }
.cb-sidebar-icon:hover { background: #333; color: #fff; }
.cb-sidebar-icon.active { background: #0077dd33; color: #4aa8ff; }
.cb-sidebar-divider { width: 24px; height: 1px; background: #444; margin: 6px 0; }
.cb-main-content { flex: 1; overflow-y: auto; padding: 12px; }
/* Uniform Section Header */
.cb-section-header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin: -12px -12px 12px -12px; border-radius: 4px 4px 0 0; font-size: 16px; font-weight: bold; color: #e0e0e0; user-select: none; }
.cb-sections-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
.cb-section { background: #2a2a2a; border-radius: 4px; padding: 12px; border: 1px solid #3a3a3a; box-sizing: border-box; flex: 1 1 300px; }
.cb-section.full-width { flex: 1 1 100%; width: 100%; }
.cb-section-title { color: #0066cc; font-size: 14px; font-weight: bold; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #3a3a3a; }
.cb-flex-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.cb-input-group { flex: 1 1 180px; min-width: 140px; display: flex; flex-direction: column; }
.cb-input, .cb-select { background: #1a1a1a; border: 1px solid #555; color: white; padding: 8px 10px; border-radius: 4px; width: 100%; box-sizing: border-box; font-size: 13px; font-family: monospace; }
.cb-input:focus, .cb-select:focus { outline: none; border-color: #0066cc; }
.cb-label { color: #ccc; font-size: 12px; margin-bottom: 4px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cb-button { background: #4a4a4a; border: none; color: #e0e0e0; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; margin: 4px 2px; white-space: nowrap; }
.cb-button:hover { background: #5a5a5a; }
.cb-button.primary { background: #0077dd; }
.cb-button.primary:hover { background: #0088ee; }
.cb-button.danger { background: #cc3333; }
.cb-button.danger:hover { background: #dd4444; }
.cb-array { background: #333; border-radius: 4px; padding: 12px; margin: 8px 0; border: 1px solid #3a3a3a; width: 100%; position: relative; }
.cb-config-label-overlay { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); background: rgba(0, 102, 204, 0.85); color: #fff; padding: 4px 16px; border-radius: 12px; font-size: 13px; font-weight: bold; font-family: monospace; pointer-events: none; z-index: 10; white-space: nowrap; max-width: 80%; overflow: hidden; text-overflow: ellipsis; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
.cb-arrays-container { display: flex; flex-direction: column; gap: 12px; }
.cb-list-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px dashed #444; }
.cb-item-card { background: #2a2a2a; border-radius: 4px; padding: 10px; border-left: 3px solid #0066cc; flex: 1 1 300px; min-width: 250px; display: flex; flex-direction: column; gap: 6px; }
.cb-item-card.model-card { border-left-color: #cc6600; }
/* Drag-and-drop reorder styles */
.cb-item-card.cb-dragging { opacity: 0.4; }
.cb-item-card.cb-drop-above { border-top: 2px solid #0088ff; }
.cb-item-card.cb-drop-below { border-bottom: 2px solid #0088ff; }
.cb-drag-handle { opacity: 0.4; transition: opacity 0.15s; }
.cb-header-bar:hover .cb-drag-handle { opacity: 1; }
.cb-te-row.cb-dragging { opacity: 0.4; }
.cb-te-row.cb-drop-above { border-top: 2px solid #0088ff; }
.cb-te-row.cb-drop-below { border-bottom: 2px solid #0088ff; }
.cb-slider-container { display: flex; align-items: center; gap: 10px; }
.cb-slider { flex: 1; height: 6px; background: #1a1a1a; border-radius: 3px; outline: none; }
/* Numerical Input Styles */
.cb-number-input { width: 60px; background: #1a1a1a; color: #0066cc; border: 1px solid #3a3a3a; border-radius: 4px; padding: 2px 4px; font-size: 12px; font-family: monospace; text-align: right; margin-left: 8px; }
.cb-number-input:focus { outline: none; border-color: #0066cc; }
.cb-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 6px 0; }
.cb-preview { background: #0a0a0a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 10px; margin: 10px 0; max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 11px; color: #0cc; }
.cb-controls-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 10px; padding: 8px; background: #2a2a2a; border-radius: 4px; }
.cb-header-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 2px 0; }
.cb-header-bar:hover { background: rgba(255,255,255,0.05); border-radius: 4px; }
.cb-header-left { display: flex; align-items: center; gap: 8px; overflow: hidden; flex: 1; }
.cb-header-name { color: #fff; font-weight: bold; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 10px; }
.cb-section-toggle { cursor: pointer; display: flex; justify-content: space-between; align-items: center; width: 100%; user-select: none; }
.cb-modal-popup {
background: #2a2a2a;
border: 2px solid #9966cc;
border-radius: 8px;
padding: 20px;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
color: white;
position: relative;
width: 95%; /* Responsive width */
box-sizing: border-box;
}
@media (max-width: 600px) {
.cb-modal-popup {
padding: 10px; /* Smaller padding on mobile */
width: 98%; /* Use more screen space on mobile */
}
}
/* --- Prompt Builder Styles --- */
.cb-prompt-section {
background: #252525;
border-radius: 4px;
padding: 10px;
margin-top: 8px;
width: 100%;
box-sizing: border-box;
}
.cb-prompt-section.global {
border-left: 3px solid #00aa44;
}
.cb-prompt-section.per-config {
border-left: 3px solid #9966cc;
}
.cb-prompt-group {
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cb-prompt-group-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #888;
}
.cb-prompt-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 28px;
align-items: center;
}
.cb-prompt-chip {
display: inline-flex;
align-items: center;
background: #0066cc33;
border: 1px solid #0066cc66;
color: #88ccff;
border-radius: 12px;
padding: 3px 8px;
font-size: 12px;
font-family: monospace;
gap: 4px;
max-width: 300px;
word-break: break-all;
}
.cb-prompt-chip .chip-close {
cursor: pointer;
color: #ff8888;
font-weight: bold;
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.cb-prompt-chip .chip-close:hover {
color: #ff4444;
}
.cb-prompt-preview {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 4px;
padding: 8px;
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 11px;
color: #00cc88;
}
.cb-prompt-preview-item {
padding: 2px 0;
border-bottom: 1px solid #1a1a1a;
}
.cb-prompt-preview-item:last-child {
border-bottom: none;
}
.cb-prompt-raw-editor {
width: 100%;
min-height: 160px;
background: #0a0a0a;
border: 1px solid #3a3a3a;
color: #0cc;
padding: 8px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
resize: vertical;
box-sizing: border-box;
}
.cb-prompt-raw-editor:focus {
outline: none;
border-color: #0066cc;
}
.cb-prompt-mode-toggle {
background: transparent;
border: 1px solid #555;
color: #aaa;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
}
.cb-prompt-mode-toggle:hover {
background: #333;
color: #fff;
}
.cb-prompt-mode-toggle.active {
background: #0066cc33;
border-color: #0066cc;
color: #88ccff;
}
.cb-prompt-add-group-btn {
width: 100%;
border: 1px dashed #555;
background: rgba(0,102,204,0.1);
color: #88ccff;
padding: 6px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-top: 4px;
}
.cb-prompt-add-group-btn:hover {
background: rgba(0,102,204,0.2);
border-color: #0066cc;
}
.cb-prompt-add-variation {
background: transparent;
border: 1px dashed #444;
color: #888;
padding: 3px 8px;
border-radius: 12px;
cursor: pointer;
font-size: 11px;
}
.cb-prompt-add-variation:hover {
background: #333;
color: #ccc;
border-color: #666;
}
/* Per-config sidebar navigation */
.cb-sidebar-config-group {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.cb-sidebar-config-icon {
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #999;
transition: background 0.15s;
}
.cb-sidebar-config-icon:hover {
background: #333;
}
.cb-sidebar-config-icon.active {
background: #333;
color: #fff;
}
.cb-sidebar-sub-icons {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 2px 0;
}
.cb-sidebar-sub-icon {
width: 28px;
height: 22px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 9px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #888;
transition: background 0.15s;
}
.cb-sidebar-sub-icon:hover {
background: #444;
color: #fff;
}
</style>
`;
}
// --- SECTION HEADER ACCENTS ---
const SECTION_ACCENTS = {
prompts: "#00cc55",
configs: "#0088ff",
distribution: "#ff8800",
preview: "#00cccc"
};
// --- UNIFORM SECTION HEADER ---
export function createSectionHeader(emoji, title, accentKey, { collapsible = false, collapsed = false, onToggle = null } = {}) {
const accent = SECTION_ACCENTS[accentKey] || "#0088ff";
const header = document.createElement("div");
header.className = "cb-section-header";
header.style.cssText = `background: linear-gradient(90deg, ${accent}22, transparent); border-left: 3px solid ${accent};`;
const titleSpan = document.createElement("span");
titleSpan.textContent = `${emoji} ${title}`;
header.appendChild(titleSpan);
if (collapsible) {
header.style.cursor = "pointer";
const arrow = document.createElement("span");
arrow.textContent = collapsed ? "▶" : "▼";
arrow.style.cssText = "margin-left: auto; font-size: 12px; color: #888;";
header.appendChild(arrow);
header.onclick = () => {
if (onToggle) onToggle();
};
}
return header;
}
// --- TOP BAR COMPONENT ---
export function createTopBar(node, {
availableSessions, availableConfigs,
refreshAllConfigBuilders,
onLoadSession, onLoadConfig, onSaveConfig
}) {
const bar = document.createElement("div");
bar.className = "cb-top-bar";
// Session name input
const sessionInput = document.createElement("input");
sessionInput.className = "cb-top-bar-input";
sessionInput.type = "text";
sessionInput.value = node.state.session_name || "my_test_session";
sessionInput.placeholder = "Session name...";
sessionInput.title = "Session Name";
sessionInput.onchange = () => {
node.state.session_name = sessionInput.value;
node.saveState();
};
bar.appendChild(sessionInput);
// Settings button + dropdown container
const settingsWrapper = document.createElement("div");
settingsWrapper.style.position = "relative";
const settingsBtn = document.createElement("button");
settingsBtn.className = "cb-settings-btn";
settingsBtn.innerHTML = "⚙ Settings ▾";
const dropdown = _createSettingsDropdown(node, {
availableSessions, availableConfigs,
refreshAllConfigBuilders,
onLoadSession, onLoadConfig, onSaveConfig
});
settingsBtn.onclick = (e) => {
e.stopPropagation();
dropdown.classList.toggle("open");
};
// Close on click outside
document.addEventListener("click", (e) => {
if (!settingsWrapper.contains(e.target)) {
dropdown.classList.remove("open");
}
});
settingsWrapper.appendChild(settingsBtn);
settingsWrapper.appendChild(dropdown);
bar.appendChild(settingsWrapper);
return bar;
}
// --- SETTINGS DROPDOWN (internal) ---
function _createSettingsDropdown(node, {
availableSessions, availableConfigs,
refreshAllConfigBuilders,
onLoadSession, onLoadConfig, onSaveConfig
}) {
const dropdown = document.createElement("div");
dropdown.className = "cb-settings-dropdown";
// Helper: create expandable item
function addExpandableItem(emoji, label, expandContent) {
const item = document.createElement("div");
item.className = "cb-settings-item";
item.innerHTML = `<span>${emoji}</span><span>${label}</span><span style="margin-left:auto;font-size:10px;color:#666">▶</span>`;
const expand = document.createElement("div");
expand.className = "cb-settings-expand";
expand.appendChild(expandContent);
item.onclick = (e) => {
e.stopPropagation();
// Close other expands
dropdown.querySelectorAll(".cb-settings-expand.open").forEach(el => {
if (el !== expand) el.classList.remove("open");
});
expand.classList.toggle("open");
const arrow = item.querySelector("span:last-child");
arrow.textContent = expand.classList.contains("open") ? "▼" : "▶";
};
dropdown.appendChild(item);
dropdown.appendChild(expand);
}
// Helper: create toggle item
function addToggleItem(emoji, label, checked, onChange) {
const item = document.createElement("label");
item.className = "cb-settings-item";
item.style.cursor = "pointer";
const iconSpan = document.createElement("span");
iconSpan.textContent = emoji;
item.appendChild(iconSpan);
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
item.appendChild(cb);
const lbl = document.createElement("span");
lbl.textContent = label;
item.appendChild(lbl);
dropdown.appendChild(item);
}
// Helper: divider
function addDivider() {
const d = document.createElement("div");
d.className = "cb-settings-divider";
dropdown.appendChild(d);
}
// === Load Session (expandable) ===
const sessionSelectContainer = document.createElement("div");
const sessionSelect = createSearchableSelect(
availableSessions || [],
"",
(value) => {
if (value && value !== "None") {
onLoadSession(value);
dropdown.classList.remove("open");
}
},
"Search sessions..."
);
sessionSelectContainer.appendChild(sessionSelect);
addExpandableItem("📂", "Load Session", sessionSelectContainer);
// === Load Config (expandable) ===
const configSelectContainer = document.createElement("div");
const configSelect = createSearchableSelect(
availableConfigs || [],
"",
(value) => {
if (value && value !== "None") {
onLoadConfig(value);
dropdown.classList.remove("open");
}
},
"Search configs..."
);
configSelectContainer.appendChild(configSelect);
addExpandableItem("📂", "Load Config", configSelectContainer);
// === Save Config (expandable) ===
const saveContainer = document.createElement("div");
saveContainer.style.cssText = "display: flex; gap: 6px; align-items: center;";
const saveNameInput = document.createElement("input");
saveNameInput.className = "cb-input";
saveNameInput.style.cssText = "flex: 1; font-size: 12px; padding: 6px 8px;";
saveNameInput.value = node.state.config_name || "";
saveNameInput.placeholder = "config_name";
saveNameInput.onchange = () => {
node.state.config_name = saveNameInput.value;
node.saveState();
};
const saveBtn = document.createElement("button");
saveBtn.className = "cb-button primary";
saveBtn.textContent = "💾 Save";
saveBtn.style.cssText = "font-size: 12px; padding: 6px 12px;";
saveBtn.onclick = async (e) => {
e.stopPropagation();
node.state.config_name = saveNameInput.value;
await onSaveConfig();
saveBtn.textContent = "✅ Saved!";
setTimeout(() => { saveBtn.textContent = "💾 Save"; }, 2000);
};
saveContainer.appendChild(saveNameInput);
saveContainer.appendChild(saveBtn);
addExpandableItem("💾", "Save Config", saveContainer);
addDivider();
// === Auto-Save toggle ===
addToggleItem("💾", "Auto-Save (2s)", node.state.auto_save || false, (val) => {
node.state.auto_save = val;
node.saveState();
});
addDivider();
// === Enable Distribution toggle ===
addToggleItem("🌐", "Enable Distribution", node.state.distribution_enabled || false, (val) => {
node.state.distribution_enabled = val;
node.saveState();
node.renderUI();
});
addDivider();
// === Refresh Models action ===
const refreshItem = document.createElement("div");
refreshItem.className = "cb-settings-item";
const refreshIcon = document.createElement("span");
refreshIcon.textContent = "🔄";
refreshItem.appendChild(refreshIcon);
const refreshLabel = document.createElement("span");
refreshLabel.textContent = "Refresh Models & LoRAs";
refreshItem.appendChild(refreshLabel);
refreshItem.onclick = async () => {
refreshLabel.textContent = "Refreshing...";
await refreshAllConfigBuilders();
refreshLabel.textContent = "✅ Refreshed!";
setTimeout(() => {
refreshLabel.textContent = "Refresh Models & LoRAs";
}, 2000);
};
dropdown.appendChild(refreshItem);
// === Label Mode toggle ===
addToggleItem("🏷️", "Label Mode", node.state.label_mode || false, (val) => {
node.state.label_mode = val;
node.saveState();
node.renderUI();
});
// === Include None toggle ===
addToggleItem("⬜", "Include None", node.state.include_none || false, (val) => {
node.state.include_none = val;
node.saveState();
});
// === Include Default toggle ===
addToggleItem("⬜", "Include Default", node.state.include_default || false, (val) => {
node.state.include_default = val;
node.saveState();
});
return dropdown;
}
// --- SIDEBAR COMPONENT ---
export function createSidebar(node, mainContent, refreshAllConfigBuilders) {
const sidebar = document.createElement("div");
sidebar.className = "cb-sidebar";
const icons = [];
// Navigation icons
function addNavIcon(emoji, title, targetId) {
const btn = document.createElement("button");
btn.className = "cb-sidebar-icon";
btn.textContent = emoji;
btn.title = title;
btn.dataset.targetId = targetId;
btn.onclick = () => {
const target = mainContent.querySelector(`#${targetId}`);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
};
sidebar.appendChild(btn);
icons.push(btn);
return btn;
}
// Distribution icon (only when enabled, appears first)
if (node.state.distribution_enabled) {
addNavIcon("🌐", "Distribution", "cb-sec-distribution");
}
addNavIcon("📝", "Global Prompts", "cb-sec-prompts");
addNavIcon("⚙️", "Config Arrays", "cb-sec-configs");
// Per-config array navigation icons
const configColors = ["#0088ff", "#ff6600", "#00cc66", "#cc44cc", "#ffaa00", "#44cccc", "#ff4466", "#88aa00"];
if (node.state.config_arrays && node.state.config_arrays.length > 0) {
node.state.config_arrays.forEach((ca, idx) => {
const color = configColors[idx % configColors.length];
const group = document.createElement("div");
group.className = "cb-sidebar-config-group";
const cogBtn = document.createElement("button");
cogBtn.className = "cb-sidebar-config-icon";
cogBtn.style.color = color;
cogBtn.style.borderLeft = `2px solid ${color}`;
cogBtn.textContent = `⚙️${idx + 1}`;
cogBtn.title = ca.name || `Config ${idx + 1}`;
cogBtn.dataset.targetId = `cb-config-${idx}`;
cogBtn.onclick = () => {
const target = mainContent.querySelector(`#cb-config-${idx}`);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
};
icons.push(cogBtn);
group.appendChild(cogBtn);
// Sub-icons container (always visible)
const subIcons = document.createElement("div");
subIcons.className = "cb-sidebar-sub-icons";
const subSections = [
{ emoji: "🧊", label: "Models", id: `cb-config-${idx}-models` },
{ emoji: "🔣", label: "Text Enc", id: `cb-config-${idx}-te` },
{ emoji: "🎨", label: "VAE", id: `cb-config-${idx}-vae` },
{ emoji: "🔮", label: "LoRAs", id: `cb-config-${idx}-loras` }
];
subSections.forEach(sub => {
const subBtn = document.createElement("button");
subBtn.className = "cb-sidebar-sub-icon";
subBtn.textContent = sub.emoji;
subBtn.title = sub.label;
subBtn.onclick = () => {
const target = mainContent.querySelector(`#${sub.id}`);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
};
subIcons.appendChild(subBtn);
});
group.appendChild(subIcons);
sidebar.appendChild(group);
});
}
addNavIcon("📄", "JSON Preview", "cb-sec-preview");
// Divider
const divider = document.createElement("div");
divider.className = "cb-sidebar-divider";
sidebar.appendChild(divider);
// Quick action: Add Config
const addBtn = document.createElement("button");
addBtn.className = "cb-sidebar-icon";
addBtn.textContent = "+";
addBtn.title = "Add Config";
addBtn.style.cssText = "font-size: 20px; font-weight: bold;";
addBtn.onclick = () => {
node.state.config_arrays.push({
name: `Config ${node.state.config_arrays.length + 1}`,
samplers: ["euler"],
schedulers: ["normal"],
steps: "20",
cfg: "7.0",
seed_behavior: "fixed",
full_run_seed_behavior: "fixed",
full_run_seed: 0,
models: ["None"],
vaes: ["None"],
text_encoders: [],
clip_type: "stable_diffusion",
gguf_options: {},
loras: ["None"],
lora_omit_triggers: [],
lora_triggerwords_append_settings: {},
lora_bypass_states: {},
lora_strength_lock: {},
model_bypass_states: {},
vae_bypass_states: {},
te_bypass_states: {},
combine: false,
positive_prompt_groups: [],
negative_prompt: "",
use_custom_prompts: false,
model_prompt_prefix: "",
model_prompt_suffix: "",
attention_modes: ["default"],
resolutions: [],
model_sampling_override: "none",
model_sampling_shift: "1.73",
model_sampling_flux_max_shift: "1.15",
model_sampling_flux_base_shift: "0.5",
use_advanced_sampling: false,
advanced_guider: "cfg_guider",
advanced_scheduler: "basic",
use_flux_guidance: false,
flux_guidance_value: "3.5"
});
node.saveState();
node.renderUI();
};
sidebar.appendChild(addBtn);
// Quick action: Refresh Models
const refreshBtn = document.createElement("button");
refreshBtn.className = "cb-sidebar-icon";
refreshBtn.textContent = "🔄";
refreshBtn.title = "Refresh Models & LoRAs";
refreshBtn.onclick = async () => {
refreshBtn.style.opacity = "0.5";
await refreshAllConfigBuilders();
refreshBtn.style.opacity = "1";
};
sidebar.appendChild(refreshBtn);
// Scroll spy: highlight active section
const sectionIds = ["cb-sec-prompts", "cb-sec-configs"];
// Add per-config section IDs for scroll spy
if (node.state.config_arrays) {
node.state.config_arrays.forEach((_, idx) => {
sectionIds.push(`cb-config-${idx}`);
});
}
sectionIds.push("cb-sec-distribution", "cb-sec-preview");
const updateActiveIcon = () => {
const scrollTop = mainContent.scrollTop;
let activeId = sectionIds[0];
for (const id of sectionIds) {
const el = mainContent.querySelector(`#${id}`);
if (el && el.offsetTop - 20 <= scrollTop) {
activeId = id;
}
}
icons.forEach(btn => {
btn.classList.toggle("active", btn.dataset.targetId === activeId);
});
};
mainContent.addEventListener("scroll", updateActiveIcon);
// Initial highlight
setTimeout(updateActiveIcon, 50);
return sidebar;
}