- Add 4px inset padding to hover button overlay so buttons stay within grid item bounds when node size increases - Raise triangle button z-index from 2 to 4 (above hover buttons at 3) so it remains clickable when hover buttons are visible
625 lines
27 KiB
JavaScript
625 lines
27 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, onReplace, onOverride, onToggleBypass, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
|
const isDefault = !!entry.inline;
|
|
const isBypassed = !!entry.bypassed;
|
|
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" : isBypassed ? "#1a1a1a" : "#2a2a2a"
|
|
};border:1px solid ${
|
|
isDefault ? "#3a5a8a" : isBypassed ? "#333" : "#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;` : ""
|
|
}${isBypassed ? "opacity:0.45;" : ""}`);
|
|
|
|
// Type ribbon (top-left) — always visible on non-default entries,
|
|
// defaulting to "Other" when the flake has no explicit flake_type set.
|
|
// Clicking toggles the bypassed state.
|
|
const TYPE_COLORS = {
|
|
Style: "#8a6acf", Slider: "#6a9acf", Character: "#6acf8a",
|
|
Pose: "#cf8a6a", Other: "#cf6a8a",
|
|
};
|
|
if (!isDefault) {
|
|
const typeTag = entry._pendingData?.flake_type || entry.flake_type || "Other";
|
|
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"; });
|
|
ribbon.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (onToggleBypass) onToggleBypass(idx);
|
|
});
|
|
block.appendChild(ribbon);
|
|
}
|
|
|
|
// Strikethrough for bypassed state
|
|
if (isBypassed && !isDefault) {
|
|
const strike = document.createElement("div");
|
|
css(strike, "position:absolute;top:50%;left:10%;right:10%;height:2px;background:rgba(200,60,60,0.7);transform:translateY(-50%);z-index:4;pointer-events:none;");
|
|
block.appendChild(strike);
|
|
}
|
|
|
|
// 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 — show portion after / with word wrapping
|
|
const fullName = isDefault ? "Default" : (entry.display_name || entry.name || "(missing)");
|
|
const nameAfterSlash = fullName.includes("/") ? 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;z-index:1;word-break:break-word;hyphens:auto;");
|
|
nameEl.textContent = nameAfterSlash;
|
|
block.appendChild(nameEl);
|
|
|
|
// Whole block is draggable for reorder. The browser only initiates a
|
|
// drag after the cursor moves a few pixels with the button held, so
|
|
// single clicks (Edit / Remove / Replace buttons) and double-clicks
|
|
// still register normally.
|
|
if (!isDefault) {
|
|
block.draggable = true;
|
|
block.style.cursor = "grab";
|
|
block.addEventListener("dragstart", (e) => {
|
|
// Don't start a drag when the user clicks one of the hover buttons
|
|
if (e.target && e.target !== block && e.target.tagName === "BUTTON") {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
block.style.cursor = "grabbing";
|
|
onDragStart(e, idx, block);
|
|
});
|
|
block.addEventListener("dragend", () => {
|
|
block.style.cursor = "grab";
|
|
onDragEnd(block);
|
|
});
|
|
}
|
|
|
|
// Override button (always pinned top-right, even when not hovered)
|
|
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: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:4;");
|
|
ov.addEventListener("click", (e) => { e.stopPropagation(); onOverride(idx); });
|
|
block.appendChild(ov);
|
|
}
|
|
|
|
// Hover button group: Replace / Edit / Remove (only visible on hover)
|
|
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;
|
|
}
|
|
|
|
if (!isDefault) {
|
|
const hoverWrap = document.createElement("div");
|
|
css(hoverWrap, "position:absolute;top:4px;left:4px;right:4px;bottom:4px;display:none;align-items:center;justify-content:center;gap:6px;z-index:3;background:rgba(0,0,0,0.35);border-radius:4px;");
|
|
const HOVER_BTN = "width:22px;height:22px;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);";
|
|
|
|
const replaceBtn = document.createElement("button");
|
|
replaceBtn.title = "Replace Flake";
|
|
replaceBtn.appendChild(svgIcon(`<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`));
|
|
css(replaceBtn, HOVER_BTN);
|
|
replaceBtn.addEventListener("click", (e) => { e.stopPropagation(); onReplace(idx); });
|
|
hoverWrap.appendChild(replaceBtn);
|
|
|
|
const editBtn = document.createElement("button");
|
|
editBtn.title = "Edit Flake";
|
|
editBtn.appendChild(svgIcon(`<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`));
|
|
css(editBtn, HOVER_BTN);
|
|
editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(idx); });
|
|
hoverWrap.appendChild(editBtn);
|
|
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.title = "Remove Flake";
|
|
removeBtn.appendChild(svgIcon(`<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>`));
|
|
css(removeBtn, HOVER_BTN);
|
|
removeBtn.addEventListener("click", (e) => { e.stopPropagation(); onRemove(idx); });
|
|
hoverWrap.appendChild(removeBtn);
|
|
|
|
block.appendChild(hoverWrap);
|
|
block.addEventListener("mouseenter", () => { hoverWrap.style.display = "flex"; });
|
|
block.addEventListener("mouseleave", () => { hoverWrap.style.display = "none"; });
|
|
}
|
|
|
|
// Triangle button (bottom center) for variants / 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:4;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 variants";
|
|
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.variant || {})[group] || "");
|
|
dd.element.addEventListener("change", () => {
|
|
if (dd.element.value) {
|
|
entry.variant = entry.variant || {};
|
|
entry.variant[group] = dd.element.value;
|
|
} else {
|
|
if (entry.variant) delete entry.variant[group];
|
|
}
|
|
onChanged();
|
|
});
|
|
row.appendChild(dd.container);
|
|
panel.appendChild(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadOptions() {
|
|
if (optionsLoaded || !entry.name) return;
|
|
try {
|
|
const [variants, fdata] = await Promise.all([fetchFlakeMeta(entry.name), fetchFlake(entry.name)]);
|
|
optionsLoaded = true;
|
|
hasOptions = variants;
|
|
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; }
|
|
|
|
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;");
|
|
|
|
// 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,
|
|
onReplace: handleReplace,
|
|
onOverride: handleOverride,
|
|
onToggleBypass: handleToggleBypass,
|
|
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]._edited_at = Date.now();
|
|
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();
|
|
// If the flake was moved on disk, update the entry's name to point
|
|
// at the new location.
|
|
if (result.name && result.name !== arr[idx].name) arr[idx].name = result.name;
|
|
arr[idx]._pendingData = result.data;
|
|
arr[idx]._edited_at = Date.now();
|
|
arr[idx].flake_type = result.data?.flake_type || null;
|
|
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
|
|
// Clear per-instance overrides so the grid item reflects the new
|
|
// defaults instead of masking them with stale values.
|
|
const newLoras = Array.isArray(result.data?.loras)
|
|
? result.data.loras.map(l => l?.strength ?? 1.0)
|
|
: (result.data?.path ? [result.data.strength ?? 1.0] : []);
|
|
arr[idx].loras = newLoras;
|
|
if (arr[idx].variant) {
|
|
const validGroups = new Set(Object.keys(result.data?.variants || result.data?.options || {}));
|
|
for (const g of Object.keys(arr[idx].variant)) {
|
|
if (!validGroups.has(g)) delete arr[idx].variant[g];
|
|
}
|
|
}
|
|
writeEntries(arr);
|
|
render();
|
|
}
|
|
}
|
|
|
|
function handleRemove(idx) {
|
|
if (idx === 0) return;
|
|
const arr = readEntries();
|
|
arr.splice(idx, 1);
|
|
writeEntries(arr);
|
|
render();
|
|
}
|
|
|
|
function handleToggleBypass(idx) {
|
|
if (idx === 0) return;
|
|
const arr = readEntries();
|
|
arr[idx].bypassed = !arr[idx].bypassed;
|
|
writeEntries(arr);
|
|
render();
|
|
}
|
|
|
|
async function handleReplace(idx) {
|
|
if (idx === 0) return;
|
|
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 flake_type = 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;
|
|
flake_type = d.flake_type || null;
|
|
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
|
|
else if (d.path) loras = [d.strength ?? 1.0];
|
|
} catch {}
|
|
arr[idx] = { name: result.name, loras, variant: {}, has_lora, display_name, flake_type };
|
|
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,
|
|
family: getFamily(),
|
|
});
|
|
if (!result || !result.created) return;
|
|
const arr = readEntries();
|
|
let has_lora = false;
|
|
let display_name = null;
|
|
let loras = [];
|
|
let flake_type = result.data?.flake_type || null;
|
|
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))); flake_type = flake_type || d.flake_type || null; } 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; flake_type = flake_type || d.flake_type || 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];
|
|
flake_type = flake_type || d.flake_type || null;
|
|
} catch {}
|
|
}
|
|
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
|
|
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 flake_type = 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;
|
|
flake_type = d.flake_type || 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, variant: {}, has_lora, display_name, flake_type });
|
|
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();
|
|
});
|
|
|
|
// React to native family widget changes
|
|
if (familyWidget) {
|
|
const origCallback = familyWidget.callback;
|
|
familyWidget.callback = function (value) {
|
|
const r = origCallback?.apply(this, arguments);
|
|
render();
|
|
return r;
|
|
};
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|