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.
523 lines
22 KiB
JavaScript
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 = "▾";
|
|
return;
|
|
}
|
|
panel.style.display = "flex";
|
|
if (triangleBtn) triangleBtn.innerHTML = "▴";
|
|
|
|
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();
|
|
}
|