1743 lines
74 KiB
JavaScript
1743 lines
74 KiB
JavaScript
// 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 = "▾";
|
||
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;
|
||
}
|