Files

1743 lines
74 KiB
JavaScript
Raw Permalink 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.
// Folder holding the animate ("anim" kind) flakes, presets and outputs (#445).
export const ANIM_DIR = "video";
// ---------- 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 = {};
// Fields of an unregistered family, the ControlNet types its
// `controlnet_<type>_<folder>` names cover, and the family folders on disk
// (#470). Filled by ensureFamilies().
let GENERIC_FAMILY = null;
let CN_TYPES = [];
export const FAMILY_DISK_FOLDERS = [];
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;
}
GENERIC_FAMILY = d.generic || null;
CN_TYPES = d.cn_types || [];
FAMILY_DISK_FOLDERS.splice(0, Infinity, ...(d.folders || []));
return FAMILY_SPECS;
})
.catch(() => FAMILY_SPECS);
}
return _familiesPromise;
}
// The model_family field is free text (#470), resolved like
// flake_families.get: a registered family by label or folder in any case,
// else a new family whose folder is the slugged value.
function registeredLabel(family) {
const key = String(family || "").trim().toLowerCase();
if (!key) return null;
return Object.keys(FAMILY_FOLDERS).find(label => label.toLowerCase() === key || FAMILY_FOLDERS[label] === key) || null;
}
// Mirrors flake_families.folder_slug: `Moss/Base` -> `moss_base`.
function folderSlug(family) {
return String(family || "").trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
}
// The value a model_family widget stores: the registered label, or the trimmed text.
export function familyLabel(family) {
return registeredLabel(family) || String(family || "").trim();
}
export function familyFolder(family) {
const label = registeredLabel(family);
return label ? FAMILY_FOLDERS[label] : folderSlug(family) || null;
}
export function familySpec(family) {
const label = registeredLabel(family);
if (label) return FAMILY_SPECS[label] || null;
const folder = folderSlug(family);
if (!folder || !GENERIC_FAMILY) return null;
const cn_models = Object.fromEntries(CN_TYPES.map(t => [t, `controlnet_${t}_${folder}`]));
return { ...GENERIC_FAMILY, label: familyLabel(family), folder, cn_models };
}
// model_family suggestions: the selectable registered labels, then family
// folders on disk that no registered family owns (e.g. a created `moss`).
function familySuggestions() {
const registered = new Set(Object.values(FAMILY_FOLDERS));
return [
...Object.keys(FAMILY_FOLDERS).filter(label => FAMILY_SPECS[label]?.selectable !== false),
...FAMILY_DISK_FOLDERS.filter(folder => !registered.has(folder)),
];
}
// Free-text model_family field with suggestions (#472), drawn in place of the
// node's native widget. The widget keeps the value and its callback still
// fires, so preset lists and downstream stacks refresh as before. A value
// naming a registered family is stored as its label (`anima` -> `Anima/Base`).
// Returns a function that re-reads the widget value (after a workflow load).
export function attachFamilyInput(node, widget) {
widget.computeSize = () => [0, -4];
widget.type = "hidden";
widget.hidden = true;
if (widget.element) { widget.element.remove(); widget.element = null; }
if (widget.inputEl) { widget.inputEl.remove(); widget.inputEl = null; }
const dd = makeSearchableDropdown([], widget.value || "", "Model family, e.g. anima");
ensureFamilies().then(() => {
for (const value of familySuggestions()) dd.datalist.appendChild(Object.assign(document.createElement("option"), { value }));
});
dd.element.addEventListener("change", () => {
const value = familyLabel(dd.element.value);
if (!familyFolder(value) || value === widget.value) {
dd.element.value = widget.value || "";
return;
}
widget.value = dd.element.value = value;
widget.callback?.(value);
node.setDirtyCanvas?.(true, true);
});
const ui = node.addDOMWidget("model_family_ui", "div", dd.container, { serialize: false });
ui.computeSize = () => [node.size[0], 36];
// Where the native widget was drawn.
node.widgets.splice(node.widgets.indexOf(ui), 1);
node.widgets.splice(node.widgets.indexOf(widget) + 1, 0, ui);
return () => {
if (document.activeElement !== dd.element) dd.element.value = widget.value || "";
};
}
// ControlNet type -> model name the family infers.
export function familyCnModels(family) {
return CN_MODEL_MAP[familyFolder(family)] || familySpec(family)?.cn_models || 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;
}
// Hover scope button at the top left of an image box (#478): opens `img`
// full size (without its `thumb` param) over the modal. Click or Esc closes
// the preview only; the box's own click (the picker) is not triggered.
export function addImageZoom(box, img) {
const btn = document.createElement("button");
btn.type = "button";
btn.title = "Preview image";
btn.appendChild(svgIcon('<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>', 12));
css(btn, "position:absolute;top:6px;left:6px;z-index:2;width:22px;height:22px;padding:0;border-radius:4px;background:rgba(20,20,20,0.85);color:#ddd;border:1px solid #555;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity 0.12s ease;");
const hasImage = () => img.style.display !== "none" && !!img.getAttribute("src");
box.addEventListener("mouseenter", () => { if (hasImage()) btn.style.opacity = "1"; });
box.addEventListener("mouseleave", () => { btn.style.opacity = "0"; });
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
if (!hasImage()) return;
const overlay = document.createElement("div");
css(overlay, "position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out;");
const full = document.createElement("img");
full.src = img.src.replace(/&thumb=\d+/, "");
css(full, "max-width:94vw;max-height:94vh;object-fit:contain;border-radius:4px;box-shadow:0 8px 40px rgba(0,0,0,0.6);");
overlay.appendChild(full);
const close = () => {
overlay.remove();
window.removeEventListener("keydown", onKey, true);
};
// Capture phase: Esc closes the preview before the flake modal sees it.
const onKey = (ev) => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopImmediatePropagation();
close();
};
overlay.addEventListener("click", (ev) => { ev.stopPropagation(); close(); });
window.addEventListener("keydown", onKey, true);
document.body.appendChild(overlay);
});
box.appendChild(btn);
}
// ---------- 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 };
}
// Image (or video) with its filename (full path in the tooltip) and `w × h`
// under it: Flake Generate's output (#329) and Flake Animate Stack's first
// input image (#442). `onReveal` handles a double-click on the media.
export function makeImageSection({ video = false, emptyText, onReveal = null }) {
const el = document.createElement("div");
css(el, "display:flex;flex-direction:column;align-items:center;gap:4px;margin-top:4px;");
const imageEl = document.createElement("img");
css(imageEl, "max-width:100%;border-radius:4px;display:none;");
el.appendChild(imageEl);
const videoEl = document.createElement("video");
// No native controls: their double-click goes fullscreen instead of revealing (#439).
Object.assign(videoEl, { loop: true, muted: true, autoplay: true, title: "Click to play/pause, double-click to reveal in file explorer" });
css(videoEl, "max-width:100%;border-radius:4px;display:none;cursor:pointer;");
videoEl.addEventListener("click", () => (videoEl.paused ? videoEl.play() : videoEl.pause()));
el.appendChild(videoEl);
if (onReveal) {
imageEl.title = "Double-click to reveal in file explorer";
imageEl.style.cursor = "pointer";
imageEl.addEventListener("dblclick", onReveal);
videoEl.addEventListener("dblclick", onReveal);
}
const noImageLabel = document.createElement("div");
css(noImageLabel, "font-size:11px;opacity:0.4;text-align:center;padding:8px 0;");
noImageLabel.textContent = emptyText;
el.appendChild(noImageLabel);
// Read-only filename label (filename only, full path in tooltip) + dimensions
const filenameLabel = document.createElement("div");
css(filenameLabel, "width:100%;box-sizing:border-box;color:#ccc;padding:0 4px;font-size:12px;font-family:inherit;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none;cursor:default;");
el.appendChild(filenameLabel);
const dimensionsLabel = document.createElement("div");
css(dimensionsLabel, "width:100%;box-sizing:border-box;color:#888;padding:0 4px;font-size:11px;font-family:inherit;text-align:center;display:none;");
el.appendChild(dimensionsLabel);
function showDimensions(w, h) {
dimensionsLabel.textContent = `${w} × ${h}`;
dimensionsLabel.style.display = "block";
}
videoEl.addEventListener("loadedmetadata", () => showDimensions(videoEl.videoWidth, videoEl.videoHeight));
// `name` (with `path` as its tooltip) hides the filename label when empty.
function show(url, name, path) {
noImageLabel.style.display = "none";
imageEl.style.display = video ? "none" : "block";
videoEl.style.display = video ? "block" : "none";
if (video) {
videoEl.src = url;
} else {
imageEl.src = url;
}
if (name) {
filenameLabel.textContent = name;
filenameLabel.title = path;
filenameLabel.style.display = "block";
} else {
filenameLabel.style.display = "none";
}
if (video) return;
imageEl.onload = () => {
if (imageEl.naturalWidth && imageEl.naturalHeight) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight);
};
if (imageEl.complete && imageEl.naturalWidth) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight);
}
function clear() {
imageEl.style.display = "none";
videoEl.style.display = "none";
videoEl.pause();
noImageLabel.style.display = "block";
filenameLabel.style.display = "none";
dimensionsLabel.style.display = "none";
}
// A transient image (e.g. a live sampling preview, #374): no labels.
function showPreview(url) {
imageEl.onload = null;
imageEl.src = url;
imageEl.style.display = "block";
videoEl.style.display = "none";
videoEl.pause();
noImageLabel.style.display = "none";
filenameLabel.style.display = "none";
dimensionsLabel.style.display = "none";
}
return { el, imageEl, show, clear, showPreview };
}
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;
}
// Grid dropdown strength sliders for the LoRAs of one variant choice (#413).
// Values live in owner.variant_loras[group][choice][i]; an unset slot shows
// fallback[i], then the choice's yaml strength. A null choice renders nothing.
export function renderVariantLoraSliders(host, variants, owner, group, choice, onChanged, fallback = []) {
host.replaceChildren();
const loras = choice ? variants?.[group]?.[choice]?.loras : null;
if (!Array.isArray(loras)) return;
loras.forEach((lr, i) => {
const label = document.createElement("div");
label.textContent = lr?.name || String(lr?.path || "LoRA").split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
const slider = makeSmallValueSlider(owner.variant_loras?.[group]?.[choice]?.[i] ?? fallback[i] ?? lr?.strength ?? 1.0, -10, 10, 0.05, (v) => {
const slots = (((owner.variant_loras ||= {})[group] ||= {})[choice] ||= []);
while (slots.length <= i) slots.push(null);
slots[i] = v;
onChanged();
});
host.append(label, slider);
});
}
// Grid dropdown toggles for the options of a flake or variant choice (#465),
// three per row. entry.option_overrides[name] holds a toggle only while it
// differs from the option's yaml default.
export function renderOptionToggles(host, options, entry, onChanged) {
host.replaceChildren();
css(host, "display:grid;grid-template-columns:repeat(3,1fr);gap:2px;");
for (const opt of Array.isArray(options) ? options : []) {
if (!opt?.name) continue;
const btn = document.createElement("button");
btn.textContent = opt.name;
btn.title = [opt.name, opt.positive, opt.negative && `negative: ${opt.negative}`].filter(Boolean).join("\n");
const isOn = () => entry.option_overrides?.[opt.name] ?? !!opt.default;
const paint = () => css(btn, `min-width:0;padding:2px;font-size:9px;border-radius:3px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid ${isOn() ? "#4a9eff;background:#1f3a5c;color:#fff" : "#333;background:#1a1a1a;color:#777"};`);
btn.addEventListener("click", () => {
const on = !isOn();
if (on === !!opt.default) {
delete entry.option_overrides[opt.name];
if (!Object.keys(entry.option_overrides).length) delete entry.option_overrides;
} else {
(entry.option_overrides ||= {})[opt.name] = on;
}
paint();
onChanged();
});
paint();
host.appendChild(btn);
}
}
// ---------- 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;flex-wrap:wrap;align-content:center;align-items:center;justify-content:center;gap:2px;z-index:3;background:rgba(0,0,0,0.35);border-radius:3px;padding:2px;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:0;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 name
// the user opted to surface on the flake item. Only the first one tags it, from
// the flake's LoRAs then those of its selected variant choices (#476). Returns
// [] when none.
export function loraTagNames(data, variant) {
const selected = effectiveVariant(variant, variantDefaultsOf(data?.variants));
const loras = [data?.loras, ...Object.entries(data?.variants || {}).map(([g, choices]) => choices?.[selected[g]]?.loras)].flat();
const lora = loras.find(l => l && l.tag_name && (l.name || "").trim());
return lora ? [lora.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;
}
// A flake's {group: default choice} map from its variants: a group's first
// choice when it has `default: true`, as flake_io.variant_defaults (#414, #419).
export function variantDefaultsOf(variants) {
const out = {};
for (const [g, choices] of Object.entries(variants || {})) {
const [first, data] = Object.entries(choices || {})[0] || [];
if (data?.default) out[g] = first;
}
return out;
}
// [group, choice, flake_links] of the effective variant choices that link
// flakes; a "Multiple" (array) group has none (#420).
export function choiceFlakeLinks(variants, variant) {
return Object.entries(effectiveVariant(variant, variantDefaultsOf(variants)))
.filter(([g, c]) => typeof c === "string" && variants?.[g]?.[c]?.flake_links?.length)
.map(([g, c]) => [g, c, variants[g][c].flake_links]);
}
// 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), and All Variants shows
// "[*]" (#421). Returns "" when nothing is selected.
export function variantInitials(entry, defaults) {
if (entry?._all_variants) return "[*]";
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;
}
// Workflow-local flakes and presets (#437), staged from a dropped image's
// bundle: dashed border and a "Local" tag until saved on disk.
export const isLocalName = (name) => typeof name === "string" && name.startsWith("_local/");
export const SAVE_TO_DISK_SVG = `<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>`;
export function markLocal(block) {
block.style.borderStyle = "dashed";
const tag = document.createElement("div");
tag.textContent = "Local";
tag.title = "Temporary flake from this workflow — not saved on disk";
css(tag, "position:absolute;top:2px;left:50%;transform:translateX(-50%);z-index:6;padding:0 4px;border-radius:3px;background:rgba(224,160,64,0.9);color:#1a1a1a;font-size:9px;font-weight:700;line-height:13px;");
block.appendChild(tag);
return tag;
}
// 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 ----------
// Item dragged out of a Flake Stack / Flake Combo grid, so another node's grid
// can accept the drop; take() removes it from the source node (#415). `kind`
// keeps animate flakes ("anim") out of image grids and the other way round.
export const flakeDrag = { node: null, entry: null, take: null, kind: null };
export function clearFlakeDrag() {
flakeDrag.node = null;
flakeDrag.entry = null;
flakeDrag.take = null;
flakeDrag.kind = null;
}
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();
}
}
// `onImport` adds an "Import flake" entry above "New flake" (animate grids, #433).
export function makeAddBlock({ onNew, onLoad, onImport = null, 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;");
if (onImport) {
const importBtn = document.createElement("button");
importBtn.textContent = "⇣ Import flake";
css(importBtn, newBtn.style.cssText);
importBtn.addEventListener("click", () => {
hideMenu();
onImport();
});
menu.appendChild(importBtn);
}
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;
}