import { css, ensureDefault, makeSmallButton, _showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock, makePanelDropdown, makeSmallValueSlider, bindSelectZoom, } 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); bindSelectZoom(node, familySelect); // 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(); }