Files
JeyzerMC-comfyui-flakes/web/widgets/flake-stack.js
T
Mehdi Chaid c0b8ed68b9 fix(family-dropdown): enlarge and stop click propagation (#67)
The Family dropdown was too small to comfortably click on the Flake
Stack node and clicks were leaking to the LiteGraph canvas on the
Flake Model Preset node, where they triggered the underlying preset
combo. Increase font size, padding, and min-height across all four
widgets, and stop mousedown/click propagation so the canvas no longer
intercepts dropdown opens.

Also introduce a shared FAMILY_FOLDERS / familyFolder helper in utils
to be used by upcoming path-aware features.
2026-05-05 16:19:43 -04:00

523 lines
22 KiB
JavaScript

import {
css, ensureDefault, makeSmallButton,
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock,
makePanelDropdown, makeSmallValueSlider,
} from "../utils.js";
import { fetchList, fetchFlake, saveFlakeApi, getCoverUrl, fetchFlakeMeta } from "../api.js";
import { openEditModal } from "../flake-modal.js";
import { openFileLoadPicker } from "../pickers.js";
function makeBlock({ entry, idx, onEdit, onRemove, onOverride, onDragStart, onDragOver, onDrop, onDragEnd }) {
const isDefault = !!entry.inline;
const hasCover = !isDefault && entry.name;
const block = document.createElement("div");
block.dataset.idx = String(idx);
block.dataset.flakeBlock = "1";
css(block, `position:relative;height:80px;background:${
isDefault ? "#2a3a4a" : "#2a2a2a"
};border:1px solid ${
isDefault ? "#3a5a8a" : "#444"
};border-radius:4px;cursor:pointer;font-size:11px;color:#ddd;user-select:none;box-sizing:border-box;${
hasCover ? `background-image:url(${getCoverUrl(entry.name)});background-size:cover;background-position:center;` : ""
}`);
// Dark overlay for cover readability
if (hasCover) {
const overlay = document.createElement("div");
css(overlay, "position:absolute;inset:0;background:rgba(0,0,0,0.45);pointer-events:none;z-index:0;");
block.appendChild(overlay);
}
// Name
const fullName = isDefault ? "Default" : (entry.display_name || entry.name || "(missing)");
const shortName = fullName.split(/[\/\\ _\-]+/).pop() || fullName;
const nameEl = document.createElement("div");
nameEl.title = fullName;
css(nameEl, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;text-align:center;line-height:1.2;text-shadow:0 1px 3px rgba(0,0,0,0.8);padding:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;z-index:1;");
nameEl.textContent = shortName;
block.appendChild(nameEl);
// Drag handle (left edge vertical line)
if (!isDefault) {
const dragHandle = document.createElement("div");
css(dragHandle, "position:absolute;left:0;top:20%;bottom:20%;width:3px;background:#555;border-radius:2px;cursor:grab;z-index:2;");
dragHandle.draggable = true;
dragHandle.addEventListener("dragstart", (e) => { onDragStart(e, idx, block); });
dragHandle.addEventListener("dragend", () => { onDragEnd(block); });
block.appendChild(dragHandle);
}
// Override button
if (!isDefault && entry._pendingData) {
const ov = document.createElement("button");
ov.textContent = "\uD83D\uDCBE";
ov.title = "Save changes to disk";
css(ov, "position:absolute;top:2px;right:20px;width:16px;height:16px;line-height:14px;text-align:center;font-size:10px;background:rgba(0,0,0,0.5);color:#ddd;border:1px solid #555;border-radius:2px;cursor:pointer;padding:0;z-index:2;");
ov.addEventListener("click", (e) => { e.stopPropagation(); onOverride(idx); });
block.appendChild(ov);
}
// Remove button
if (!isDefault) {
const rm = document.createElement("button");
rm.textContent = "\u2715";
rm.title = "Remove from stack";
css(rm, "position:absolute;top:2px;right:2px;width:16px;height:16px;line-height:14px;text-align:center;font-size:10px;background:rgba(0,0,0,0.5);color:#ddd;border:1px solid #555;border-radius:2px;cursor:pointer;padding:0;z-index:2;");
rm.addEventListener("click", (e) => { e.stopPropagation(); onRemove(idx); });
block.appendChild(rm);
}
// Triangle button (bottom center) for options / LoRA
let triangleBtn = null;
if (!isDefault && entry.name) {
triangleBtn = document.createElement("button");
triangleBtn.innerHTML = "▾"; // down-pointing triangle
css(triangleBtn, "position:absolute;bottom:2px;left:50%;transform:translateX(-50%);background:transparent;color:rgba(180,180,180,0.6);border:none;padding:0;font-size:12px;line-height:1;cursor:pointer;z-index:2;display:none;");
triangleBtn.addEventListener("click", (e) => {
e.stopPropagation();
});
triangleBtn.addEventListener("dblclick", (e) => e.stopPropagation());
triangleBtn.addEventListener("mousedown", (e) => e.stopPropagation());
block.appendChild(triangleBtn);
}
block.addEventListener("dblclick", () => onEdit(idx));
block.addEventListener("dragover", (e) => onDragOver(e, idx, block));
block.addEventListener("dragleave", () => { block.style.outline = ""; block.style.boxShadow = ""; });
block.addEventListener("drop", (e) => onDrop(e, idx, block));
return { block, triangleBtn };
}
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn) {
if (entry.inline) return { toggleOptionsPanel: () => {} };
// Options panel (hidden by default)
const panel = document.createElement("div");
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);width:160px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;display:none;flex-direction:column;gap:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
panel.addEventListener("click", (e) => e.stopPropagation());
panel.addEventListener("dblclick", (e) => e.stopPropagation());
block.appendChild(panel);
let optionsLoaded = false;
let hasOptions = false;
let flakeData = null;
function rebuildPanel() {
panel.replaceChildren();
const lorasMeta = entry._pendingData?.loras || flakeData?.loras || [];
// Sync override array length to match the YAML
if (entry.loras.length > lorasMeta.length) {
entry.loras.length = lorasMeta.length;
}
while (entry.loras.length < lorasMeta.length) {
entry.loras.push(lorasMeta[entry.loras.length]?.strength ?? 1.0);
}
const hasLoras = lorasMeta.length > 0;
const hasOptionGroups = hasOptions && Object.keys(hasOptions).length > 0;
// Show triangle only if there's something to tweak
if (triangleBtn) {
triangleBtn.style.display = (hasLoras || hasOptionGroups) ? "block" : "none";
}
// LoRA strength sliders at top of panel
if (hasLoras) {
for (let i = 0; i < lorasMeta.length; i++) {
const sliderRow = document.createElement("div");
css(sliderRow, "padding:2px 0;");
const name = lorasMeta[i]?.name || "LoRA";
const label = document.createElement("div");
label.textContent = name;
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
sliderRow.appendChild(label);
const strSlider = makeSmallValueSlider(entry.loras[i] != null ? entry.loras[i] : (lorasMeta[i]?.strength ?? 1.0), -10, 10, 0.05, (v) => {
entry.loras[i] = v;
onChanged();
});
sliderRow.appendChild(strSlider);
panel.appendChild(sliderRow);
}
}
if (!hasOptionGroups) {
if (!hasLoras) {
const empty = document.createElement("div");
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
empty.textContent = "no options";
panel.appendChild(empty);
}
} else {
for (const group of Object.keys(hasOptions)) {
const row = document.createElement("div");
css(row, "display:flex;flex-direction:column;gap:2px;");
const gLabel = document.createElement("span");
gLabel.textContent = group;
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
row.appendChild(gLabel);
const ddOptions = [{ value: "", label: "-" }];
for (const ch of hasOptions[group]) {
ddOptions.push({ value: ch, label: ch });
}
const dd = makePanelDropdown(ddOptions, (entry.option || {})[group] || "");
dd.element.addEventListener("change", () => {
if (dd.element.value) {
entry.option = entry.option || {};
entry.option[group] = dd.element.value;
} else {
if (entry.option) delete entry.option[group];
}
onChanged();
});
row.appendChild(dd.container);
panel.appendChild(row);
}
}
}
async function loadOptions() {
if (optionsLoaded || !entry.name) return;
try {
const [options, fdata] = await Promise.all([fetchFlakeMeta(entry.name), fetchFlake(entry.name)]);
optionsLoaded = true;
hasOptions = options;
flakeData = fdata;
rebuildPanel();
} catch { /* ignore */ }
}
async function toggleOptionsPanel() {
if (panel.style.display === "flex") {
panel.style.display = "none";
if (triangleBtn) triangleBtn.innerHTML = "&#9662;";
return;
}
panel.style.display = "flex";
if (triangleBtn) triangleBtn.innerHTML = "&#9652;";
if (!optionsLoaded && entry.name) {
panel.textContent = "";
const loading = document.createElement("div");
css(loading, "font-size:9px;opacity:0.5;text-align:center;padding:4px;");
loading.textContent = "loading...";
panel.appendChild(loading);
try {
await loadOptions();
} catch {
panel.replaceChildren();
const err = document.createElement("div");
css(err, "font-size:9px;color:#f88;padding:4px;text-align:center;");
err.textContent = "failed to load";
panel.appendChild(err);
}
}
}
// Triangle button click
if (triangleBtn) {
triangleBtn.addEventListener("click", () => {
toggleOptionsPanel();
});
}
// Load options in background so triangle shows immediately for flakes that have options/loras
loadOptions();
return { toggleOptionsPanel };
}
export function setupFlakeWidget(node) {
const flakesHidden = node.widgets?.find(w => w.name === "flakes_json");
const familyWidget = node.widgets?.find(w => w.name === "model_family");
if (!flakesHidden) return;
// Hide flakes_json STRING widget
flakesHidden.computeSize = () => [0, -4];
flakesHidden.type = "hidden";
flakesHidden.hidden = true;
if (flakesHidden.element) { flakesHidden.element.remove(); flakesHidden.element = null; }
if (flakesHidden.inputEl) { flakesHidden.inputEl.remove(); flakesHidden.inputEl = null; }
// Hide model_family combo widget
if (familyWidget) {
familyWidget.computeSize = () => [0, -4];
familyWidget.type = "hidden";
familyWidget.hidden = true;
if (familyWidget.element) { familyWidget.element.remove(); familyWidget.element = null; }
if (familyWidget.inputEl) { familyWidget.inputEl.remove(); familyWidget.inputEl = null; }
}
function getFamily() {
return familyWidget?.value || "SDXL/Base";
}
function readEntries() {
try {
const arr = JSON.parse(flakesHidden.value || "[]");
const result = ensureDefault(Array.isArray(arr) ? arr : []);
for (const entry of result) {
if (!entry.loras && entry.strength != null) {
entry.loras = [entry.strength];
}
if (!entry.loras) entry.loras = [];
}
return result;
} catch { return ensureDefault([]); }
}
function writeEntries(entries) { flakesHidden.value = JSON.stringify(entries); }
// Custom DOM widget
const container = document.createElement("div");
css(container, "display:flex;flex-direction:column;gap:2px;padding:0 6px 3px 6px;font-size:12px;color:#ddd;");
// Family dropdown
const familyRow = document.createElement("div");
css(familyRow, "display:flex;gap:8px;align-items:center;padding:4px 0;");
const familyLabel = document.createElement("span");
familyLabel.textContent = "Family:";
css(familyLabel, "font-size:11px;color:#aaa;white-space:nowrap;");
familyRow.appendChild(familyLabel);
const familySelect = document.createElement("select");
const FAMILIES = ["SDXL/Base", "SDXL/Illustrious", "SDXL/Pony", "ZImage/Base", "ZImage/Turbo"];
for (const f of FAMILIES) {
const opt = document.createElement("option");
opt.value = f;
opt.textContent = f;
if (f === getFamily()) opt.selected = true;
familySelect.appendChild(opt);
}
css(familySelect, "flex:1;background:#1a1a1a;color:#ddd;border:1px solid #444;padding:6px 10px;border-radius:4px;font-size:13px;line-height:1.4;cursor:pointer;outline:none;min-height:30px;");
familySelect.addEventListener("change", () => {
if (familyWidget) familyWidget.value = familySelect.value;
render();
});
familySelect.addEventListener("mousedown", (e) => e.stopPropagation());
familySelect.addEventListener("click", (e) => e.stopPropagation());
familyRow.appendChild(familySelect);
container.appendChild(familyRow);
// Flakes grid
const grid = document.createElement("div");
css(grid, "display:grid;grid-template-columns:repeat(auto-fill, minmax(72px, 1fr));gap:4px;");
container.appendChild(grid);
let dragSrcIdx = null;
function render() {
const entries = readEntries();
grid.replaceChildren();
for (const indicator of grid.querySelectorAll(".flake-drop-indicator")) {
indicator.remove();
}
for (let i = 0; i < entries.length; i++) {
const { block: blk, triangleBtn } = makeBlock({
entry: entries[i],
idx: i,
onEdit: handleEdit,
onRemove: handleRemove,
onOverride: handleOverride,
onDragStart: (e, idx, el) => {
dragSrcIdx = idx;
e.dataTransfer.effectAllowed = "move";
el.style.opacity = "0.4";
},
onDragOver: (e, idx, el) => {
if (dragSrcIdx === null || idx === 0 || idx === dragSrcIdx) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
_showDropIndicator(el);
},
onDrop: (e, idx, el) => {
e.preventDefault();
_hideDropIndicator(el);
if (dragSrcIdx === null || idx === 0 || idx === dragSrcIdx) return;
const arr = readEntries();
const [moved] = arr.splice(dragSrcIdx, 1);
let insertIdx = idx;
if (dragSrcIdx < idx) insertIdx--;
arr.splice(insertIdx, 0, moved);
writeEntries(arr);
dragSrcIdx = null;
render();
},
onDragEnd: (el) => {
el.style.opacity = "";
dragSrcIdx = null;
_hideAllDropIndicators();
},
});
makeInstanceControls(blk, entries[i], i, () => writeEntries(entries), triangleBtn);
grid.appendChild(blk);
}
if (grid._addBlock) grid.appendChild(grid._addBlock);
}
async function handleEdit(idx) {
const entries = readEntries();
const entry = entries[idx];
const isDefault = !!entry.inline;
let data;
if (isDefault) {
data = JSON.parse(JSON.stringify(entry.content || {}));
} else {
try {
data = entry._pendingData ? JSON.parse(JSON.stringify(entry._pendingData)) : await fetchFlake(entry.name);
} catch (err) {
window.alert(`Failed to load ${entry.name}: ${err.message || err}`);
return;
}
}
const { directories } = await fetchList(getFamily());
const result = await openEditModal({
mode: isDefault ? "default" : "edit",
name: entry.name,
data,
dirs: directories,
});
if (!result) return;
if (result.defaultUpdated) {
const arr = readEntries();
arr[idx].content = result.data;
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
writeEntries(arr);
render();
} else if (result.deleted) {
const arr = readEntries().filter((_, i) => i !== idx);
writeEntries(ensureDefault(arr));
render();
} else if (result.saved) {
const arr = readEntries();
arr[idx]._pendingData = result.data;
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
writeEntries(arr);
render();
}
}
function handleRemove(idx) {
if (idx === 0) return;
const arr = readEntries();
arr.splice(idx, 1);
writeEntries(arr);
render();
}
async function handleOverride(idx) {
const entries = readEntries();
const entry = entries[idx];
if (!entry.name || !entry._pendingData) {
window.alert("No pending changes to save.");
return;
}
try {
await saveFlakeApi(entry.name, entry._pendingData);
const arr = readEntries();
delete arr[idx]._pendingData;
arr[idx].has_lora = !!(entry._pendingData && (entry._pendingData.path || (entry._pendingData.loras && entry._pendingData.loras.length > 0)));
writeEntries(arr);
render();
} catch (err) {
window.alert(`Save failed: ${err.message || err}`);
}
}
async function handleNew() {
const { directories } = await fetchList(getFamily());
const result = await openEditModal({
mode: "create",
data: {},
dirs: directories,
});
if (!result || !result.created) return;
const arr = readEntries();
let has_lora = false;
let display_name = null;
let loras = [];
if (result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0))) has_lora = true;
else if (result.name) {
try { const d = await fetchFlake(result.name); has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0))); } catch {}
}
if (result.data && result.data.name) display_name = result.data.name;
else if (result.name) {
try { const d = await fetchFlake(result.name); display_name = d.name || null; } catch {}
}
if (result.data && result.data.loras) {
loras = result.data.loras.map(l => l.strength ?? 1.0);
} else if (result.data && result.data.path) {
loras = [result.data.strength ?? 1.0];
} else if (result.name) {
try {
const d = await fetchFlake(result.name);
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
}
arr.push({ name: result.name, loras, option: {}, has_lora, display_name });
writeEntries(arr);
render();
}
async function handleLoad() {
const { flakes, directories } = await fetchList(getFamily());
const result = await openFileLoadPicker({ flakes, directories, family: getFamily() });
if (!result || !result.name) return;
const arr = readEntries();
let has_lora = false;
let display_name = null;
let loras = [];
try {
const d = await fetchFlake(result.name);
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
display_name = d.name || null;
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
else if (d.path) loras = [d.strength ?? 1.0];
} catch {}
arr.push({ name: result.name, loras, option: {}, has_lora, display_name });
writeEntries(arr);
render();
}
grid._addBlock = makeAddBlock({ onNew: handleNew, onLoad: handleLoad });
// Allow dropping after the last item onto the add block
grid._addBlock.addEventListener("dragover", (e) => {
if (dragSrcIdx === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
_showDropIndicator(grid._addBlock);
});
grid._addBlock.addEventListener("dragleave", () => {
_hideDropIndicator(grid._addBlock);
});
grid._addBlock.addEventListener("drop", (e) => {
e.preventDefault();
_hideDropIndicator(grid._addBlock);
if (dragSrcIdx === null) return;
const arr = readEntries();
const [moved] = arr.splice(dragSrcIdx, 1);
arr.push(moved);
writeEntries(arr);
dragSrcIdx = null;
render();
});
// ---- Widget registration ----
node._flakes_render = render;
const flakeWidget = node.addDOMWidget("flakes_ui", "div", container, { serialize: false, margin: 4 });
flakeWidget.computeSize = () => {
const rows = Math.max(1, Math.ceil((readEntries().length + 1) / 2));
return [node.size[0], rows * 84 + 31];
};
writeEntries(readEntries());
render();
}