Files
JeyzerMC-comfyui-flakes/web/utils.js
T

1408 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Model family ----------
//
// The server owns the family registry (flake_families.py, #355). These literals
// are a bootstrap fallback only: they are what the module exports until
// loadFamilies() resolves, so a failed fetch degrades to the previous behaviour
// instead of breaking the editor. Anything family-derived that renders after
// startup should await ensureFamilies().
export const FAMILY_FOLDERS = {
"SDXL/Base": "sdxl",
"SDXL/Illustrious": "illustrious",
"SDXL/Pony": "pony",
"ZImage/Base": "zib",
"ZImage/Turbo": "zit",
"Anima/Base": "anima",
"Krea2/Turbo": "krea2",
"Flux/Klein": "flux_klein",
"Common": "common",
};
// label -> full spec from /flakes/families (clip_type, sampling defaults,
// supports_clip_skip, default_shift). Empty until ensureFamilies() resolves.
export const FAMILY_SPECS = {};
let _familiesPromise = null;
export function ensureFamilies() {
if (!_familiesPromise) {
_familiesPromise = fetch("/flakes/families")
.then(r => (r.ok ? r.json() : null))
.then(d => {
const list = d?.families;
if (!Array.isArray(list) || !list.length) return FAMILY_SPECS;
// Only clear the fallback once we know we have a real payload.
for (const k of Object.keys(FAMILY_FOLDERS)) delete FAMILY_FOLDERS[k];
for (const f of list) {
FAMILY_FOLDERS[f.label] = f.folder;
FAMILY_SPECS[f.label] = f;
if (f.cn_models) CN_MODEL_MAP[f.folder] = f.cn_models;
}
return FAMILY_SPECS;
})
.catch(() => FAMILY_SPECS);
}
return _familiesPromise;
}
export function familyFolder(family) {
return FAMILY_FOLDERS[family] || null;
}
export function familySpec(family) {
return FAMILY_SPECS[family] || null;
}
// label -> family folder, reversed. Used to recognise an `img/<folder>/` prefix.
export function familyFromFolder(folder) {
for (const [label, f] of Object.entries(FAMILY_FOLDERS)) {
if (f === folder) return label;
}
return null;
}
// Bootstrap fallback for flake_io._CN_MODEL_MAP; replaced per-family by
// ensureFamilies(). Krea2 is intentionally absent here — it only exists on
// builds new enough to serve /flakes/families.
export const CN_MODEL_MAP = {
sdxl: {
openpose: "controlnet_openpose_sdxl",
depth: "controlnet_depth_sdxl",
canny: "controlnet_canny_sdxl",
lineart: "controlnet_lineart_sdxl",
lineart_anime: "controlnet_lineart_anime_sdxl",
softedge: "controlnet_softedge_sdxl",
scribble: "controlnet_scribble_sdxl",
normalbae: "controlnet_normalbae_sdxl",
seg: "controlnet_seg_sdxl",
tile: "controlnet_tile_sdxl",
ip2p: "controlnet_ip2p_sdxl",
},
illustrious: {
openpose: "controlnet_openpose_sdxl",
depth: "controlnet_depth_sdxl",
canny: "controlnet_canny_sdxl",
lineart: "controlnet_lineart_sdxl",
lineart_anime: "controlnet_lineart_anime_sdxl",
softedge: "controlnet_softedge_sdxl",
scribble: "controlnet_scribble_sdxl",
normalbae: "controlnet_normalbae_sdxl",
seg: "controlnet_seg_sdxl",
tile: "controlnet_tile_sdxl",
ip2p: "controlnet_ip2p_sdxl",
},
pony: {
openpose: "controlnet_openpose_sdxl",
depth: "controlnet_depth_sdxl",
canny: "controlnet_canny_sdxl",
lineart: "controlnet_lineart_sdxl",
lineart_anime: "controlnet_lineart_anime_sdxl",
softedge: "controlnet_softedge_sdxl",
scribble: "controlnet_scribble_sdxl",
normalbae: "controlnet_normalbae_sdxl",
seg: "controlnet_seg_sdxl",
tile: "controlnet_tile_sdxl",
ip2p: "controlnet_ip2p_sdxl",
},
zib: {
openpose: "Z-Image-Turbo-Fun-Controlnet-Union",
depth: "Z-Image-Turbo-Fun-Controlnet-Union",
canny: "Z-Image-Turbo-Fun-Controlnet-Union",
softedge: "Z-Image-Turbo-Fun-Controlnet-Union",
},
zit: {
openpose: "Z-Image-Turbo-Fun-Controlnet-Union",
depth: "Z-Image-Turbo-Fun-Controlnet-Union",
canny: "Z-Image-Turbo-Fun-Controlnet-Union",
softedge: "Z-Image-Turbo-Fun-Controlnet-Union",
},
common: {
openpose: "controlnet_openpose_sdxl",
depth: "controlnet_depth_sdxl",
canny: "controlnet_canny_sdxl",
lineart: "controlnet_lineart_sdxl",
lineart_anime: "controlnet_lineart_anime_sdxl",
softedge: "controlnet_softedge_sdxl",
scribble: "controlnet_scribble_sdxl",
normalbae: "controlnet_normalbae_sdxl",
seg: "controlnet_seg_sdxl",
tile: "controlnet_tile_sdxl",
ip2p: "controlnet_ip2p_sdxl",
},
anima: {
openpose: "anima-lllite-pose",
depth: "anima-lllite-depth",
lineart: "anima-lllite-lineart",
scribble: "anima-lllite-scribble",
},
flux_klein: {
openpose: "controlnet_openpose_flux",
depth: "controlnet_depth_flux",
canny: "controlnet_canny_flux",
lineart: "controlnet_lineart_flux",
lineart_anime: "controlnet_lineart_anime_flux",
softedge: "controlnet_softedge_flux",
scribble: "controlnet_scribble_flux",
normalbae: "controlnet_normalbae_flux",
seg: "controlnet_seg_flux",
tile: "controlnet_tile_flux",
ip2p: "controlnet_ip2p_flux",
},
};
// Global registry for mutually-exclusive option panels (flake combo/stack dropdowns).
// Each panel registers a close function; opening a new panel closes all others.
const _openPanels = new Set();
export function _registerOpenPanel(closeFn) {
_closeAllPanels();
_openPanels.add(closeFn);
}
export function _unregisterOpenPanel(closeFn) {
_openPanels.delete(closeFn);
}
function _closeAllPanels() {
for (const fn of _openPanels) fn();
}
// ---------- Widget show/hide ----------
// Hide a node widget so it neither renders nor takes vertical space, but
// stays in node.widgets so prompt serialization still includes its value
// (necessary for widgets bound to REQUIRED Python inputs).
//
// Different ComfyUI frontend versions render widgets differently:
// - LiteGraph canvas-drawn COMBO/STRING widgets render via `widget.draw`.
// - The modern Vue/DOM frontend uses `widget.element` and/or `widget.inputEl`.
// - Both versions usually honor `widget.computeSize` returning [0, -4] to
// collapse the row height.
// We stack every known signal so at least one is honored.
const _widgetHideState = new WeakMap();
export function setWidgetHidden(widget, hide) {
if (!widget) return;
let saved = _widgetHideState.get(widget);
if (hide) {
if (saved) return; // already hidden
const state = widget._state || null; // modern ComfyUI proxy mirror
saved = {
type: widget.type,
computeSize: widget.computeSize,
draw: widget.draw,
options: widget.options,
elementDisplay: widget.element ? widget.element.style.display : null,
inputElDisplay: widget.inputEl ? widget.inputEl.style.display : null,
stateType: state ? state.type : null,
stateDisabled: state ? state.disabled : null,
stateLabel: state ? state.label : null,
stateHidden: state ? state.hidden : null,
};
_widgetHideState.set(widget, saved);
widget.computeSize = () => [0, -4];
widget.type = "hidden";
widget.hidden = true;
widget.draw = function () {};
widget.options = { ...(widget.options || {}), serialize: true };
if (widget.element) {
widget.element.style.setProperty("display", "none", "important");
let parent = widget.element.parentElement;
while (parent && !parent.classList.contains("comfy-widgets-divider") && parent.parentElement) {
parent = parent.parentElement;
}
if (parent) parent.style.setProperty("display", "none", "important");
}
if (widget.inputEl) widget.inputEl.style.setProperty("display", "none", "important");
// Mirror onto _state (#216 v3): on modern frontends the canvas-drawn
// ComboWidget renders from this Proxy rather than the top-level widget
// object. Setting type='hidden' here is what actually suppresses draw.
if (state) {
try { state.type = "hidden"; } catch { /* proxy may be read-only */ }
try { state.disabled = true; } catch { /* */ }
try { state.label = ""; } catch { /* */ }
// The modern frontend's litegraph skips drawing a widget whose `.hidden`
// is set; the canvas renderer reads this from the _state proxy, so
// setting it here is what actually removes the widget from view (not
// just disables it). Mirroring type/disabled alone left it visible.
try { state.hidden = true; } catch { /* */ }
}
} else {
if (!saved) return; // already visible
widget.type = saved.type;
widget.computeSize = saved.computeSize;
if (saved.draw) widget.draw = saved.draw;
else delete widget.draw;
if (saved.options) widget.options = saved.options;
widget.hidden = false;
if (widget.element && saved.elementDisplay !== null) {
widget.element.style.removeProperty("display");
widget.element.style.display = saved.elementDisplay;
}
if (widget.inputEl && saved.inputElDisplay !== null) {
widget.inputEl.style.removeProperty("display");
widget.inputEl.style.display = saved.inputElDisplay;
}
// Restore parent container display
if (widget.element) {
let parent = widget.element.parentElement;
while (parent && !parent.classList.contains("comfy-widgets-divider") && parent.parentElement) {
parent = parent.parentElement;
}
if (parent) parent.style.removeProperty("display");
}
const state = widget._state || null;
if (state) {
if (saved.stateType !== null) try { state.type = saved.stateType; } catch { /* */ }
if (saved.stateDisabled !== null) try { state.disabled = saved.stateDisabled; } catch { /* */ }
if (saved.stateLabel !== null) try { state.label = saved.stateLabel; } catch { /* */ }
try { state.hidden = saved.stateHidden == null ? false : saved.stateHidden; } catch { /* */ }
}
_widgetHideState.delete(widget);
}
}
// Wrap a textarea (or any block element) in a relative container with an
// absolute-positioned ✕ button anchored to the top-right corner. The button
// is invisible by default and fades in when the container is hovered. Used
// for the prompt-field remove affordance (#226) so the X consumes no layout
// space when not hovered.
export function makeHoverRemoveWrapper(child, onRemove, title = "Remove") {
const wrap = document.createElement("div");
css(wrap, "position:relative;flex:1;min-width:0;display:flex;flex-direction:column;");
wrap.appendChild(child);
const btn = document.createElement("button");
btn.textContent = "✕";
btn.title = title;
btn.type = "button";
css(btn, [
"position:absolute;top:4px;right:4px;z-index:2;",
"width:18px;height:18px;padding:0;border-radius:3px;",
"background:rgba(40,40,40,0.85);color:#bbb;",
"border:1px solid #444;cursor:pointer;font-size:11px;line-height:1;",
"display:flex;align-items:center;justify-content:center;",
"opacity:0;transition:opacity 0.12s ease;",
].join(""));
wrap.addEventListener("mouseenter", () => { btn.style.opacity = "1"; });
wrap.addEventListener("mouseleave", () => { btn.style.opacity = "0"; });
btn.addEventListener("focus", () => { btn.style.opacity = "1"; });
btn.addEventListener("blur", () => { btn.style.opacity = "0"; });
btn.addEventListener("mouseenter", () => { btn.style.background = "rgba(90,40,40,0.95)"; btn.style.color = "#fdd"; });
btn.addEventListener("mouseleave", () => { btn.style.background = "rgba(40,40,40,0.85)"; btn.style.color = "#bbb"; });
btn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); onRemove(); });
wrap.appendChild(btn);
return wrap;
}
// ---------- Zoom scaling for native <select> in DOM widgets ----------
// ---------- Default-flake helpers ----------
export function makeDefaultEntry() {
return {
inline: true,
content: { prompt: { positive: "" } },
loras: [],
strength: 1.0,
option: {},
};
}
export function ensureDefault(entries) {
if (!entries.length || !entries[0].inline) entries.unshift(makeDefaultEntry());
return entries;
}
// ---------- Style helpers ----------
export const css = (el, s) => { el.style.cssText = s; return el; };
export function makeButton(label, primary = false) {
const b = document.createElement("button");
b.textContent = label;
css(b, `padding:6px 12px;cursor:pointer;border-radius:3px;font-size:12px;${
primary
? "background:#2a6acf;color:#fff;border:1px solid #2a6acf;"
: "background:#2a2a2a;color:#ddd;border:1px solid #444;"
}`);
return b;
}
export function makeSmallButton(label) {
const b = document.createElement("button");
b.textContent = label;
css(b, "padding:2px 6px;font-size:10px;cursor:pointer;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;");
return b;
}
// Accent "chip" button matching the Generation Data button (#338): dark fill,
// subtle blue border + hover, blue semibold label. Returns { btn, lbl } so the
// caller can flip the label text (e.g. Activate all / Deactivate all). Pass an
// optional `icon` (emoji/text) shown before the label.
export function makeAccentButton(label, { icon = "" } = {}) {
const ACCENT = "#4a9eff";
const btn = document.createElement("div");
css(btn, `flex:0 0 auto;background:#222;border:1px solid ${ACCENT}44;border-radius:8px;padding:6px 10px;cursor:pointer;transition:border-color 0.15s,background 0.15s;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:30px;box-sizing:border-box;white-space:nowrap;`);
if (icon) {
const ic = document.createElement("span");
ic.textContent = icon;
ic.style.fontSize = "14px";
btn.appendChild(ic);
}
const lbl = document.createElement("span");
lbl.textContent = label;
css(lbl, `font-size:11px;font-weight:600;color:${ACCENT};`);
btn.appendChild(lbl);
btn.addEventListener("mouseenter", () => { btn.style.borderColor = ACCENT; btn.style.background = "#282828"; });
btn.addEventListener("mouseleave", () => { btn.style.borderColor = ACCENT + "44"; btn.style.background = "#222"; });
return { btn, lbl };
}
export function makeIconBtn(text, title, onClick) {
const b = document.createElement("button");
b.textContent = text;
b.title = title;
css(b, "width:18px;height:18px;padding:0;cursor:pointer;background:#2a2a2a;color:#aaa;border:1px solid #444;border-radius:3px;font-size:10px;line-height:16px;text-align:center;flex-shrink:0;");
b.addEventListener("click", onClick);
b.addEventListener("dblclick", (e) => e.stopPropagation());
b.addEventListener("mousedown", (e) => e.stopPropagation());
return b;
}
export function makeInput(value = "", placeholder = "") {
const el = document.createElement("input");
el.type = "text";
el.value = value;
el.placeholder = placeholder;
css(el, "background:#2a2a2a;color:#ddd;border:1px solid #444;padding:4px 6px;border-radius:3px;font-size:12px;width:100%;box-sizing:border-box;");
return el;
}
export function makeTextarea(value = "", placeholder = "", rows = 3) {
const el = document.createElement("textarea");
el.value = value;
el.placeholder = placeholder;
el.rows = rows;
css(el, "background:#2a2a2a;color:#ddd;border:1px solid #444;padding:6px;border-radius:3px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;");
return el;
}
// Grow a textarea to fit its content up to maxPx, then scroll (#264). Returns
// the resize fn so callers can trigger it after programmatic value changes.
export function attachAutoGrow(el, maxPx = 240) {
el.style.resize = "none";
el.style.overflowY = "hidden";
const resize = () => {
el.style.height = "auto";
const h = Math.min(el.scrollHeight, maxPx);
el.style.height = `${h}px`;
el.style.overflowY = el.scrollHeight > maxPx ? "auto" : "hidden";
};
el.addEventListener("input", resize);
// scrollHeight needs layout — size on the next frame once in the DOM.
requestAnimationFrame(resize);
return resize;
}
export function makeLabel(text) {
const l = document.createElement("div");
l.textContent = text;
css(l, "font-size:11px;opacity:0.7;margin:4px 0 2px;");
return l;
}
export function makeNumberInput(value = 0, placeholder = "", step = 0.1) {
const el = document.createElement("input");
el.type = "number";
el.value = String(value);
el.placeholder = placeholder;
el.step = String(step);
css(el, "background:#2a2a2a;color:#ddd;border:1px solid #444;padding:4px 6px;border-radius:3px;font-size:12px;width:100%;box-sizing:border-box;");
return el;
}
// ----- ComfyUI-native style helpers (for modal) -----
export function makeComfyLabel(text) {
const l = document.createElement("div");
l.textContent = text;
css(l, "font-size:13px;color:#aaa;margin:10px 0 4px;font-weight:400;");
return l;
}
export function makeComfyInput(value = "", placeholder = "") {
const el = document.createElement("input");
el.type = "text";
el.value = value;
el.placeholder = placeholder;
css(el, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;outline:none;");
el.addEventListener("focus", () => { el.style.borderColor = "#555"; });
el.addEventListener("blur", () => { el.style.borderColor = "#333"; });
return el;
}
export function makeComfyDropdown(options = [], selected = "") {
const wrap = document.createElement("div");
css(wrap, "position:relative;width:100%;");
const el = document.createElement("select");
for (const opt of options) {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === selected) o.selected = true;
el.appendChild(o);
}
css(el, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;appearance:none;cursor:pointer;outline:none;");
el.addEventListener("focus", () => { el.style.borderColor = "#555"; });
el.addEventListener("blur", () => { el.style.borderColor = "#333"; });
const chevron = document.createElement("div");
chevron.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>`;
css(chevron, "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;");
wrap.appendChild(el);
wrap.appendChild(chevron);
return { element: el, container: wrap };
}
export function makePanelDropdown(options = [], selected = "") {
const wrap = document.createElement("div");
css(wrap, "position:relative;width:100%;");
const el = document.createElement("select");
for (const opt of options) {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
css(o, "font-size:13px;text-align:center;");
if (opt.value === selected) o.selected = true;
el.appendChild(o);
}
css(el, "background:#1a1a1a;color:#ddd;border:1px solid #333;border-radius:4px;font-size:13px;height:26px;width:100%;box-sizing:border-box;appearance:none;-webkit-appearance:none;-moz-appearance:none;cursor:pointer;outline:none;text-align:center;text-align-last:center;-moz-text-align-last:center;padding:0 18px;");
el.addEventListener("focus", () => { el.style.borderColor = "#555"; });
el.addEventListener("blur", () => { el.style.borderColor = "#333"; });
const chevron = document.createElement("div");
chevron.innerHTML = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>`;
css(chevron, "position:absolute;right:4px;top:50%;transform:translateY(-50%);pointer-events:none;");
wrap.appendChild(el);
wrap.appendChild(chevron);
return { element: el, container: wrap };
}
export function makeSearchableDropdown(items = [], value = "", placeholder = "") {
const wrap = document.createElement("div");
css(wrap, "position:relative;width:100%;");
const row = document.createElement("div");
css(row, "position:relative;display:flex;align-items:center;");
wrap.appendChild(row);
const el = document.createElement("input");
el.type = "text";
el.value = value;
el.placeholder = placeholder;
css(el, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 28px 6px 8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;outline:none;");
el.addEventListener("focus", () => { el.style.borderColor = "#555"; });
el.addEventListener("blur", () => { el.style.borderColor = "#333"; });
const arrowBtn = document.createElement("button");
arrowBtn.type = "button";
arrowBtn.tabIndex = -1;
arrowBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>`;
css(arrowBtn, "position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:none;cursor:pointer;padding:2px;display:flex;align-items:center;justify-content:center;z-index:2;line-height:1;");
arrowBtn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
if (dropdown.style.display === "block") {
closeDropdown();
} else {
openDropdown();
}
});
row.appendChild(el);
row.appendChild(arrowBtn);
// position:fixed so the menu escapes any overflow:auto ancestor (e.g. the
// modal body) that would otherwise clip it for fields near the bottom
// (#263). Coordinates are computed from the input on open.
const dropdown = document.createElement("div");
css(dropdown, "position:fixed;max-height:200px;overflow-y:auto;background:#1e1e1e;border:1px solid #444;border-radius:6px;z-index:10000;display:none;box-shadow:0 4px 12px rgba(0,0,0,0.5);");
wrap.appendChild(dropdown);
function positionDropdown() {
const r = el.getBoundingClientRect();
const maxH = 200;
const below = window.innerHeight - r.bottom;
dropdown.style.left = `${r.left}px`;
dropdown.style.width = `${r.width}px`;
if (below < maxH && r.top > below) {
// Not enough room below — open upward.
dropdown.style.top = "auto";
dropdown.style.bottom = `${window.innerHeight - r.top + 2}px`;
} else {
dropdown.style.bottom = "auto";
dropdown.style.top = `${r.bottom + 2}px`;
}
}
let allItems = [...items];
let dropdownOpen = false;
function renderDropdown(filter = "") {
dropdown.replaceChildren();
const lower = filter.toLowerCase();
const filtered = allItems.filter(item => !lower || item.toLowerCase().includes(lower));
if (filtered.length === 0) {
const empty = document.createElement("div");
css(empty, "padding:6px 8px;font-size:12px;color:#666;");
empty.textContent = "No matches";
dropdown.appendChild(empty);
return;
}
for (const item of filtered) {
const opt = document.createElement("div");
opt.textContent = item;
css(opt, "padding:4px 8px;cursor:pointer;font-size:12px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
opt.addEventListener("mouseenter", () => { opt.style.background = "#333"; });
opt.addEventListener("mouseleave", () => { opt.style.background = "transparent"; });
opt.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
el.value = item;
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
closeDropdown();
});
dropdown.appendChild(opt);
}
}
function _reposition() { if (dropdownOpen) positionDropdown(); }
function openDropdown() {
dropdownOpen = true;
dropdown.style.display = "block";
positionDropdown();
renderDropdown(el.value);
window.addEventListener("scroll", _reposition, true);
window.addEventListener("resize", _reposition);
}
function closeDropdown() {
dropdownOpen = false;
dropdown.style.display = "none";
window.removeEventListener("scroll", _reposition, true);
window.removeEventListener("resize", _reposition);
}
el.addEventListener("focus", () => { openDropdown(); });
el.addEventListener("input", () => {
if (dropdownOpen) renderDropdown(el.value);
});
el.addEventListener("dblclick", () => {
el.select();
openDropdown();
});
document.addEventListener("mousedown", (e) => {
if (!wrap.contains(e.target)) closeDropdown();
});
const datalist = {
appendChild(child) {
if (child.tagName === "OPTION" && child.value) {
allItems.push(child.value);
}
return child;
},
};
return { element: el, datalist, container: wrap };
}
// Multi-select chip list backed by a searchable dropdown (#294). Picking an
// option (or pressing Enter on free text) appends a chip and clears the input,
// so several values can be added in a row — unlike a single searchable input
// whose value gets replaced on each pick. `datalist.appendChild` populates the
// option list (same shape as makeSearchableDropdown); `getValues()` returns the
// selected list in order.
export function makeChipMultiSelect({ options = [], selected = [], placeholder = "" } = {}) {
const container = document.createElement("div");
css(container, "display:flex;flex-direction:column;gap:4px;");
const chosen = [...selected];
const chipRow = document.createElement("div");
css(chipRow, "display:flex;flex-wrap:wrap;gap:3px;");
container.appendChild(chipRow);
const dd = makeSearchableDropdown(options, "", placeholder);
container.appendChild(dd.container);
function renderChips() {
chipRow.replaceChildren();
chosen.forEach((val, i) => {
const chip = document.createElement("span");
css(chip, "display:inline-flex;align-items:center;gap:4px;background:#2a3a4a;border:1px solid #3a5a8a;border-radius:3px;padding:1px 6px;font-size:11px;color:#cde;");
chip.textContent = val;
const x = document.createElement("span");
x.textContent = "✕";
css(x, "cursor:pointer;color:#9ab;font-size:10px;");
x.addEventListener("click", () => { chosen.splice(i, 1); renderChips(); });
chip.appendChild(x);
chipRow.appendChild(chip);
});
}
function addValue(v) {
const val = (v || "").trim();
if (!val || chosen.includes(val)) return;
chosen.push(val);
renderChips();
}
dd.element.addEventListener("change", () => {
addValue(dd.element.value);
dd.element.value = "";
});
dd.element.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addValue(dd.element.value);
dd.element.value = "";
}
});
renderChips();
return { container, datalist: dd.datalist, getValues: () => [...chosen] };
}
export function makeComfyNumberInput(value, placeholder, step = 1) {
const el = document.createElement("input");
el.type = "number";
el.value = String(value);
el.placeholder = placeholder;
el.step = String(step);
css(el, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;outline:none;");
el.addEventListener("focus", () => { el.style.borderColor = "#555"; });
el.addEventListener("blur", () => { el.style.borderColor = "#333"; });
return el;
}
export function makeComfySlider(value, min, max, step) {
const row = document.createElement("div");
css(row, "display:flex;align-items:center;gap:0;background:#1a1a1a;border:1px solid #333;border-radius:6px;overflow:hidden;");
let current = parseFloat(value) || 0;
const clamp = (v) => Math.min(max, Math.max(min, v));
const format = (v) => Number.isInteger(step) ? String(v) : v.toFixed(step < 0.1 ? 2 : 1);
current = clamp(current);
const minusBtn = document.createElement("button");
minusBtn.textContent = "\u2212";
css(minusBtn, "width:22px;height:32px;padding:0;background:transparent;color:#888;border:none;border-right:1px solid #333;cursor:pointer;font-size:14px;line-height:1;flex-shrink:0;transition:background 0.1s;");
minusBtn.addEventListener("mouseenter", () => minusBtn.style.background = "#252525");
minusBtn.addEventListener("mouseleave", () => minusBtn.style.background = "transparent");
const valSpan = document.createElement("span");
valSpan.textContent = format(current);
css(valSpan, "flex:1;text-align:center;font-size:13px;color:#ddd;font-variant-numeric:tabular-nums;user-select:none;cursor:ew-resize;height:32px;display:flex;align-items:center;justify-content:center;");
const plusBtn = document.createElement("button");
plusBtn.textContent = "+";
css(plusBtn, "width:22px;height:32px;padding:0;background:transparent;color:#888;border:none;border-left:1px solid #333;cursor:pointer;font-size:14px;line-height:1;flex-shrink:0;transition:background 0.1s;");
plusBtn.addEventListener("mouseenter", () => plusBtn.style.background = "#252525");
plusBtn.addEventListener("mouseleave", () => plusBtn.style.background = "transparent");
function update(v) {
current = clamp(v);
valSpan.textContent = format(current);
}
minusBtn.addEventListener("click", () => update(current - step));
plusBtn.addEventListener("click", () => update(current + step));
let didDrag = false;
valSpan.addEventListener("click", (e) => {
e.stopPropagation();
if (didDrag) { didDrag = false; return; }
if (valSpan.contentEditable === "true") return;
valSpan.contentEditable = "true";
valSpan.style.userSelect = "text";
valSpan.style.cursor = "text";
valSpan.focus();
const range = document.createRange();
range.selectNodeContents(valSpan);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
function exitEdit() {
valSpan.contentEditable = "false";
valSpan.style.userSelect = "none";
valSpan.style.cursor = "ew-resize";
const v = parseFloat(valSpan.textContent);
if (!isNaN(v)) update(v);
valSpan.textContent = format(current);
}
valSpan.addEventListener("blur", exitEdit);
valSpan.addEventListener("keydown", (e) => {
if (valSpan.contentEditable !== "true") return;
e.stopPropagation();
if (e.key === "Enter") { e.preventDefault(); valSpan.blur(); }
if (e.key === "Escape") {
valSpan.contentEditable = "false";
valSpan.style.userSelect = "none";
valSpan.style.cursor = "ew-resize";
valSpan.textContent = format(current);
}
});
let dragging = false;
let startX = 0;
let startVal = 0;
const pxPerStep = 4;
valSpan.addEventListener("mousedown", (e) => {
if (valSpan.contentEditable === "true") return;
e.preventDefault();
e.stopPropagation();
dragging = true;
didDrag = false;
startX = e.clientX;
startVal = current;
valSpan.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const deltaPx = e.clientX - startX;
if (Math.abs(deltaPx) > 2) didDrag = true;
const deltaSteps = Math.round(deltaPx / pxPerStep);
const newVal = clamp(startVal + deltaSteps * step);
if (newVal !== current) {
current = newVal;
valSpan.textContent = format(current);
}
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
valSpan.style.cursor = "ew-resize";
});
row.appendChild(minusBtn);
row.appendChild(valSpan);
row.appendChild(plusBtn);
row.getValue = () => current;
return row;
}
export function makeComfyValueSlider(value, min, max, step, onChange) {
const row = document.createElement("div");
css(row, "display:flex;align-items:center;background:#1a1a1a;border:1px solid #333;border-radius:6px;overflow:hidden;height:32px;cursor:ew-resize;");
let current = parseFloat(value) || 0;
const clamp = (v) => Math.min(max, Math.max(min, v));
const format = (v) => Number.isInteger(step) ? String(v) : v.toFixed(step < 0.1 ? 2 : 1);
current = clamp(current);
const valSpan = document.createElement("span");
valSpan.textContent = format(current);
css(valSpan, "flex:1;text-align:center;font-size:13px;color:#ddd;font-variant-numeric:tabular-nums;user-select:none;height:32px;display:flex;align-items:center;justify-content:center;");
function update(v) {
current = clamp(v);
valSpan.textContent = format(current);
if (onChange) onChange(current);
}
valSpan.addEventListener("click", (e) => {
e.stopPropagation();
const input = document.createElement("input");
input.type = "number";
input.value = String(current);
input.step = String(step);
css(input, "flex:1;text-align:center;font-size:13px;color:#ddd;background:transparent;border:none;outline:none;height:32px;");
valSpan.replaceWith(input);
input.focus();
input.select();
function commit() {
const v = parseFloat(input.value);
if (!isNaN(v)) update(v);
input.replaceWith(valSpan);
valSpan.textContent = format(current);
}
input.addEventListener("blur", commit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(); }
if (e.key === "Escape") { input.replaceWith(valSpan); valSpan.textContent = format(current); }
});
});
let dragging = false;
let startX = 0;
let startVal = 0;
const pxPerStep = 4;
row.addEventListener("mousedown", (e) => {
if (e.target === valSpan) return;
e.preventDefault();
dragging = true;
startX = e.clientX;
startVal = current;
row.style.cursor = "grabbing";
});
valSpan.addEventListener("mousedown", (e) => {
e.preventDefault();
dragging = true;
startX = e.clientX;
startVal = current;
row.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const deltaPx = e.clientX - startX;
const deltaSteps = Math.round(deltaPx / pxPerStep);
const newVal = clamp(startVal + deltaSteps * step);
if (newVal !== current) {
current = newVal;
valSpan.textContent = format(current);
}
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
row.style.cursor = "ew-resize";
});
row.appendChild(valSpan);
row.getValue = () => current;
row.update = update;
return row;
}
export function makeSmallValueSlider(value, min, max, step, onChange) {
const row = document.createElement("div");
css(row, "display:flex;align-items:center;background:#1a1a1a;border:1px solid #333;border-radius:4px;overflow:hidden;height:22px;cursor:ew-resize;");
let current = parseFloat(value) || 0;
const clamp = (v) => Math.min(max, Math.max(min, v));
const format = (v) => Number.isInteger(step) ? String(v) : v.toFixed(step < 0.1 ? 2 : 1);
current = clamp(current);
const valSpan = document.createElement("span");
valSpan.textContent = format(current);
css(valSpan, "flex:1;text-align:center;font-size:10px;color:#ddd;font-variant-numeric:tabular-nums;user-select:none;height:22px;display:flex;align-items:center;justify-content:center;");
function update(v) {
current = clamp(v);
valSpan.textContent = format(current);
if (onChange) onChange(current);
}
valSpan.addEventListener("click", (e) => {
e.stopPropagation();
const input = document.createElement("input");
input.type = "number";
input.value = String(current);
input.step = String(step);
css(input, "flex:1;text-align:center;font-size:10px;color:#ddd;background:transparent;border:none;outline:none;height:22px;");
valSpan.replaceWith(input);
input.focus();
input.select();
function commit() {
const v = parseFloat(input.value);
if (!isNaN(v)) update(v);
input.replaceWith(valSpan);
valSpan.textContent = format(current);
}
input.addEventListener("blur", commit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(); }
if (e.key === "Escape") { input.replaceWith(valSpan); valSpan.textContent = format(current); }
});
});
let dragging = false;
let startX = 0;
let startVal = 0;
const pxPerStep = 4;
row.addEventListener("mousedown", (e) => {
if (e.target === valSpan) return;
e.preventDefault();
dragging = true;
startX = e.clientX;
startVal = current;
row.style.cursor = "grabbing";
});
valSpan.addEventListener("mousedown", (e) => {
e.preventDefault();
dragging = true;
startX = e.clientX;
startVal = current;
row.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const deltaPx = e.clientX - startX;
const deltaSteps = Math.round(deltaPx / pxPerStep);
const newVal = clamp(startVal + deltaSteps * step);
if (newVal !== current) {
current = newVal;
valSpan.textContent = format(current);
}
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
row.style.cursor = "ew-resize";
});
row.appendChild(valSpan);
row.getValue = () => current;
row.update = update;
return row;
}
// ---------- Shared grid item helpers ----------
export const TYPE_COLORS = {
Style: "#8a6acf", Slider: "#6a9acf", Character: "#6acf8a",
Pose: "#cf8a6a", Concept: "#6acfcf", Other: "#cf6a8a",
};
export function svgIcon(d, w = 14) {
const tpl = document.createElement("template");
tpl.innerHTML = `<svg width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
return tpl.content.firstChild;
}
const HOVER_BTN_SIZE = 26;
const HOVER_BTN_CSS = `width:${HOVER_BTN_SIZE}px;height:${HOVER_BTN_SIZE}px;padding:0;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.92);color:#222;border:none;border-radius:4px;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.5);overflow:hidden;line-height:1;box-sizing:border-box;`;
export function makeGridItemOverlay({
block,
showHoverButtons = true,
buttons = [],
showTriangle = false,
}) {
const overlay = document.createElement("div");
css(overlay, "position:absolute;inset:0;background:rgba(0,0,0,0.45);pointer-events:none;z-index:0;border-radius:3px;");
block.appendChild(overlay);
let hoverWrap = null;
if (showHoverButtons && buttons.length > 0) {
hoverWrap = document.createElement("div");
css(hoverWrap, "position:absolute;inset:0;display:none;align-items:center;justify-content:center;gap:4px;z-index:3;background:rgba(0,0,0,0.35);border-radius:3px;padding:4px;box-sizing:border-box;");
for (const btn of buttons) {
hoverWrap.appendChild(btn);
}
block.appendChild(hoverWrap);
block.addEventListener("mouseenter", () => { hoverWrap.style.display = "flex"; });
block.addEventListener("mouseleave", () => { hoverWrap.style.display = "none"; });
}
let triangleBtn = null;
if (showTriangle) {
triangleBtn = document.createElement("button");
triangleBtn.innerHTML = "&#9662;";
const triSize = Math.round(18 * 1.3);
css(triangleBtn, `position:absolute;bottom:3px;left:50%;transform:translateX(-50%);background:transparent;color:#fff;border:none;padding:0;font-size:${triSize}px;line-height:1;cursor:pointer;z-index:4;opacity:0;pointer-events:auto;`);
triangleBtn.addEventListener("click", (e) => { e.stopPropagation(); });
triangleBtn.addEventListener("dblclick", (e) => e.stopPropagation());
triangleBtn.addEventListener("mousedown", (e) => e.stopPropagation());
block.appendChild(triangleBtn);
block.addEventListener("mouseenter", () => { triangleBtn.style.opacity = "1"; });
block.addEventListener("mouseleave", () => { triangleBtn.style.opacity = "0"; });
}
return { overlay, hoverWrap, triangleBtn };
}
export function makeHoverButton({ svg, title, onClick }) {
const btn = document.createElement("button");
btn.title = title;
btn.appendChild(svgIcon(svg));
css(btn, HOVER_BTN_CSS);
btn.addEventListener("click", (e) => { e.stopPropagation(); onClick(); });
return btn;
}
export function makeTypeRibbon(entry, isBypassed, onToggleBypass, idx) {
const typeTag = entry.flake_type;
// Don't render the ribbon at all when the flake has no real type; this
// prevents showing a misleading "O" (first letter of "Other") for newly
// created flakes that haven't been assigned a type yet (#276).
if (!typeTag) return null;
const color = TYPE_COLORS[typeTag] || TYPE_COLORS.Other;
const ribbon = document.createElement("div");
ribbon.textContent = typeTag[0];
ribbon.title = isBypassed ? `${typeTag} (click to activate)` : `${typeTag} (click to bypass)`;
const bgColor = isBypassed ? "#555" : color;
css(ribbon, `position:absolute;top:0;left:0;width:16px;height:16px;background:${bgColor};color:#fff;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;border-radius:4px 0 4px 0;z-index:5;text-shadow:none;cursor:pointer;transition:opacity 0.15s;`);
ribbon.addEventListener("mouseenter", () => { ribbon.style.opacity = "0.8"; });
ribbon.addEventListener("mouseleave", () => { ribbon.style.opacity = "1"; });
if (onToggleBypass) {
ribbon.addEventListener("click", (e) => { e.stopPropagation(); onToggleBypass(idx); });
}
return ribbon;
}
// Format a flake's selected variant choices as a " (Choice, Choice)" suffix.
// Returns "" when no variant choice is selected.
export function variantSuffix(entry) {
const variant = entry?.variant;
if (!variant || typeof variant !== "object") return "";
const choices = Object.values(variant).filter(v => v != null && v !== "");
return choices.length ? ` (${choices.join(", ")})` : "";
}
// Tag names contributed by a flake's tag-name LoRAs (#274/#297): the LoRA names
// the user opted to surface on the flake item. Returns [] when none.
export function loraTagNames(loras) {
if (!Array.isArray(loras)) return [];
return loras.filter(l => l && l.tag_name && (l.name || "").trim()).map(l => l.name.trim());
}
// Split a (possibly legacy) display name into its base name and tag names. New
// entries store tags on `entry.tags`; older graphs baked them into the display
// name as "Base — tag1, tag2", so fall back to splitting on " — " (#297).
export function splitNameAndTags(displayName, tags) {
if (Array.isArray(tags)) return { name: displayName || "", tags };
const dn = displayName || "";
const at = dn.indexOf(" — ");
if (at < 0) return { name: dn, tags: [] };
return {
name: dn.slice(0, at),
tags: dn.slice(at + 3).split(",").map(s => s.trim()).filter(Boolean),
};
}
// Render a flake grid/picker label as stacked lines into `nameEl`:
// line 1: flake name
// line 2: tag names, dimmer (#297)
// line 3: variant initials "[P, C]" when `variantText` is given (#298)
// `variantText` is appended to the name line when it is a "(...)" suffix and
// rendered as its own line when it is a "[...]" initials string.
export function renderFlakeLabel(nameEl, { name, tags = [], variantText = "" } = {}) {
nameEl.replaceChildren();
nameEl.style.flexDirection = "column";
const isInitials = variantText.startsWith("[");
nameEl.title = [name + (isInitials ? "" : variantText), tags.join(", "), isInitials ? variantText : ""].filter(Boolean).join(" ");
const nameLine = document.createElement("div");
nameLine.textContent = name + (isInitials ? "" : variantText);
nameEl.appendChild(nameLine);
if (tags.length) {
const tagLine = document.createElement("div");
tagLine.textContent = tags.join(", ");
css(tagLine, "color:rgba(255,255,255,0.55);font-size:9px;font-weight:400;margin-top:1px;");
nameEl.appendChild(tagLine);
}
if (isInitials && variantText) {
const vLine = document.createElement("div");
vLine.textContent = variantText;
css(vLine, "color:rgba(255,255,255,0.78);font-size:9px;font-weight:600;margin-top:1px;");
nameEl.appendChild(vLine);
}
}
// Effective variant choices of a flake entry: its selections over the flake's
// default choices, without explicit "__none__" opt-outs (#388).
export function effectiveVariant(variant, defaults) {
const out = {};
for (const g of new Set([...Object.keys(defaults || {}), ...Object.keys(variant || {})])) {
const ch = (variant || {})[g] || (defaults || {})[g];
if (ch && ch !== "__none__") out[g] = ch;
}
return out;
}
// Variant group dropdown options: "None" first, then the choices. The default
// choice is labeled "<choice> (default)" and maps to "" (follow the default),
// so "None" is an explicit "__none__" opt-out only when a default exists (#388, #405).
export function variantDropdownOptions(choices, defaultChoice) {
const opts = [{ value: defaultChoice ? "__none__" : "", label: "None" }];
for (const ch of choices) opts.push(ch === defaultChoice ? { value: "", label: `${ch} (default)` } : { value: ch, label: ch });
return opts;
}
// Initials of a flake's effective variant choices, formatted "[P, C]" (#298).
// A "Multiple" group shows its count, e.g. "3×" (#406). Returns "" when
// nothing is selected.
export function variantInitials(entry, defaults) {
const initials = Object.values(effectiveVariant(entry?.variant, defaults))
.map(v => Array.isArray(v) ? `${v.length}×` : String(v).trim().charAt(0).toUpperCase())
.filter(Boolean);
return initials.length ? `[${initials.join(", ")}]` : "";
}
// Plain variant selections of a Flake Combo entry: the cartesian product over
// its "Multiple" (array) groups, other groups as stored (#406).
export function expandEntryVariants(entry) {
let out = [{}];
for (const [g, ch] of Object.entries(entry.variant || {})) {
out = out.flatMap(v => (Array.isArray(ch) ? ch : [ch]).map(c => ({ ...v, [g]: c })));
}
return out;
}
export function makeBypassStrike() {
const strike = document.createElement("div");
css(strike, "position:absolute;top:50%;left:10%;right:10%;height:2.5px;background:rgba(230,90,90,0.85);transform:translateY(-50%) rotate(-30deg);z-index:4;pointer-events:none;");
return strike;
}
// Gold star at the bottom-left of a favorite flake's grid item (#402).
export function makeFavoriteStar() {
const star = document.createElement("div");
star.textContent = "★";
css(star, "position:absolute;bottom:2px;left:4px;z-index:6;color:#f5c518;font-size:12px;line-height:14px;text-shadow:0 1px 3px rgba(0,0,0,0.9);pointer-events:none;");
return star;
}
// Long-press ("hold") a combo activation checkbox to "single out": activate
// only this item and deactivate every other one. A normal click still toggles
// just this item. Used by Flake Combo and Flake Model Combo (#281).
export function attachHoldToSingleOut(checkbox, onSingleOut, holdMs = 450) {
let timer = null;
let didHold = false;
const start = (e) => {
if (e.button != null && e.button !== 0) return;
didHold = false;
timer = setTimeout(() => {
timer = null;
didHold = true;
onSingleOut();
}, holdMs);
};
const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } };
checkbox.addEventListener("mousedown", start);
checkbox.addEventListener("mouseup", cancel);
checkbox.addEventListener("mouseleave", cancel);
// After a hold has fired, swallow the click/change that the release would
// otherwise produce so the normal single-item toggle does not also run.
checkbox.addEventListener("click", (e) => {
if (didHold) { e.preventDefault(); e.stopPropagation(); }
}, true);
checkbox.addEventListener("change", (e) => {
if (didHold) { e.stopPropagation(); didHold = false; }
}, true);
}
// Cached sampler/scheduler option lists from the backend (Advanced KSampler
// parity, #285). Shared by the model-preset override panel (#279).
let _samplerListCache = null;
export async function fetchSamplerLists() {
if (_samplerListCache) return _samplerListCache;
try {
const r = await fetch("/flakes/samplers");
const d = await r.json();
_samplerListCache = { samplers: d.samplers || [], schedulers: d.schedulers || [] };
} catch {
_samplerListCache = { samplers: [], schedulers: [] };
}
return _samplerListCache;
}
// Serialize a model-preset override object to the JSON consumed by the backend
// `overrides_json` widget (#279). Drops empty fields so the preset's own values
// pass through. Returns "" when there are no overrides.
export function serializeModelOverrides(ovr) {
if (!ovr || typeof ovr !== "object") return "";
const out = {};
if (ovr.filename_prefix) out.filename_prefix = ovr.filename_prefix;
if (ovr.steps != null && ovr.steps !== "") out.steps = ovr.steps;
if (ovr.cfg != null && ovr.cfg !== "") out.cfg = ovr.cfg;
if (ovr.sampler) out.sampler = ovr.sampler;
if (ovr.scheduler) out.scheduler = ovr.scheduler;
return Object.keys(out).length ? JSON.stringify(out) : "";
}
// Build a per-instance override panel for a model preset (#279): Filename
// Prefix, Steps, CFG, Sampler, Scheduler. Mutates the passed `overrides`
// object in place and calls `onChange` on every edit. Empty fields mean
// "use the preset's value".
export function makeModelOverridePanel(overrides, onChange, defaults = {}) {
// Matches the Flake Stack grid override dropdown (#302): each field shows the
// current effective value (the override if set, else the preset default), and
// Steps/CFG use the same makeSmallValueSlider as the LoRA strength sliders.
// `overrides` is mutated in place; a field only becomes an override once the
// user actually changes it (the slider fires onChange only on interaction).
// Two columns (#309): left = Filename Prefix / Sampler / Scheduler, right
// (narrower) = Steps / CFG.
const wrap = document.createElement("div");
css(wrap, "display:flex;gap:8px;padding:6px;box-sizing:border-box;");
const leftCol = document.createElement("div");
css(leftCol, "display:flex;flex-direction:column;gap:4px;width:150px;");
const rightCol = document.createElement("div");
css(rightCol, "display:flex;flex-direction:column;gap:4px;width:80px;");
wrap.appendChild(leftCol);
wrap.appendChild(rightCol);
const inputCss = "width:100%;box-sizing:border-box;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:3px 4px;border-radius:3px;font-size:10px;outline:none;";
const num = (v) => (v != null && v !== "" && !Number.isNaN(Number(v)) ? Number(v) : null);
const label = (parent, text) => {
const l = document.createElement("div");
l.textContent = text;
css(l, "font-size:9px;opacity:0.7;text-align:center;");
parent.appendChild(l);
};
label(leftCol, "Filename Prefix");
const fp = document.createElement("input");
fp.type = "text";
fp.value = (overrides.filename_prefix != null && overrides.filename_prefix !== "")
? overrides.filename_prefix : (defaults.filename_prefix || "");
fp.placeholder = "filename prefix";
css(fp, inputCss);
fp.addEventListener("input", () => { overrides.filename_prefix = fp.value || ""; onChange(); });
leftCol.appendChild(fp);
label(rightCol, "Steps");
const stepsInit = num(overrides.steps) ?? num(defaults.steps) ?? 31;
const stepsSlider = makeSmallValueSlider(stepsInit, 1, 150, 1, (v) => { overrides.steps = v; onChange(); });
rightCol.appendChild(stepsSlider);
label(rightCol, "CFG");
const cfgInit = num(overrides.cfg) ?? num(defaults.cfg) ?? 7.0;
const cfgSlider = makeSmallValueSlider(cfgInit, 0, 30, 0.1, (v) => { overrides.cfg = v; onChange(); });
rightCol.appendChild(cfgSlider);
const curSampler = overrides.sampler || defaults.sampler || "";
const curScheduler = overrides.scheduler || defaults.scheduler || "";
label(leftCol, "Sampler");
const sampDD = makePanelDropdown([], curSampler);
sampDD.element.addEventListener("change", () => { overrides.sampler = sampDD.element.value || ""; onChange(); });
leftCol.appendChild(sampDD.container);
label(leftCol, "Scheduler");
const schedDD = makePanelDropdown([], curScheduler);
schedDD.element.addEventListener("change", () => { overrides.scheduler = schedDD.element.value || ""; onChange(); });
leftCol.appendChild(schedDD.container);
fetchSamplerLists().then(({ samplers, schedulers }) => {
for (const s of samplers) {
const o = document.createElement("option");
o.value = s; o.textContent = s;
sampDD.element.appendChild(o);
}
if (curSampler) sampDD.element.value = curSampler;
for (const s of schedulers) {
const o = document.createElement("option");
o.value = s; o.textContent = s;
schedDD.element.appendChild(o);
}
if (curScheduler) schedDD.element.value = curScheduler;
});
return wrap;
}
// ---------- Drag indicator helpers ----------
export function _showDropIndicator(block) {
let indicator = block.querySelector(".flake-drop-indicator");
if (!indicator) {
indicator = document.createElement("div");
indicator.className = "flake-drop-indicator";
css(indicator, "position:absolute;left:-3px;top:0;bottom:0;width:2px;background:#2a6acf;border-radius:1px;z-index:10;pointer-events:none;");
block.appendChild(indicator);
}
}
export function _hideDropIndicator(block) {
const indicator = block.querySelector(".flake-drop-indicator");
if (indicator) indicator.remove();
}
export function _hideAllDropIndicators() {
for (const ind of document.querySelectorAll(".flake-drop-indicator")) {
ind.remove();
}
}
export function makeAddBlock({ onNew, onLoad, addLabel = "Add flake", newLabel = "+ New flake", loadLabel = "↑ Load flake" }) {
const block = document.createElement("div");
css(block, `position:relative;height:80px;background:#2a2a2a;border:1px dashed #555;border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer;font-size:11px;color:#999;user-select:none;box-sizing:border-box;`);
const icon = document.createElement("div");
css(icon, "font-size:20px;font-weight:300;color:#666;line-height:1;");
icon.textContent = "+";
block.appendChild(icon);
const label = document.createElement("div");
css(label, "font-size:9px;text-align:center;");
label.textContent = addLabel;
block.appendChild(label);
const menu = document.createElement("div");
css(menu, "position:absolute;top:100%;left:0;right:0;background:#1e1e1e;border:1px solid #444;border-radius:4px;display:none;flex-direction:column;padding:2px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:2px;");
const newBtn = document.createElement("button");
newBtn.textContent = newLabel;
css(newBtn, "width:100%;padding:6px 8px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:12px;text-align:left;margin-bottom:2px;");
const loadBtn = document.createElement("button");
loadBtn.textContent = loadLabel;
css(loadBtn, "width:100%;padding:6px 8px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:12px;text-align:left;");
menu.appendChild(newBtn);
menu.appendChild(loadBtn);
block.appendChild(menu);
let hideHandler = null;
function showMenu() {
menu.style.display = "flex";
if (!hideHandler) {
hideHandler = (e) => {
if (!block.contains(e.target)) hideMenu();
};
document.addEventListener("click", hideHandler);
}
}
function hideMenu() {
menu.style.display = "none";
if (hideHandler) {
document.removeEventListener("click", hideHandler);
hideHandler = null;
}
}
block.addEventListener("click", (e) => {
e.stopPropagation();
menu.style.display === "flex" ? hideMenu() : showMenu();
});
menu.addEventListener("click", (e) => e.stopPropagation());
newBtn.addEventListener("click", () => {
hideMenu();
onNew();
});
loadBtn.addEventListener("click", () => {
hideMenu();
onLoad();
});
block.addEventListener("dragover", (e) => e.preventDefault());
block.addEventListener("drop", (e) => e.preventDefault());
return block;
}