Add triangle options button and LoRA strength slider to grid items

- Replace inline LoRA slider with a bottom-center triangle button
- Triangle opens a dropdown panel with options and LoRA strength
- LoRA strength slider uses the same comfy styling and allows -10 to 10
- Right-click still opens the same panel
- Changes in the panel do not affect the yaml file

Closes #19
This commit is contained in:
Mehdi Chaid
2026-04-28 11:23:53 -04:00
parent 53328785f8
commit ce7620656b
+91 -134
View File
@@ -387,7 +387,7 @@ function makeComfySlider(value, min, max, step) {
return row;
}
function makeComfyValueSlider(value, min, max, step) {
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;");
@@ -403,6 +403,7 @@ function makeComfyValueSlider(value, min, max, step) {
function update(v) {
current = clamp(v);
valSpan.textContent = format(current);
if (onChange) onChange(current);
}
// Click to edit
@@ -1308,89 +1309,18 @@ function makeBlock({ entry, idx, onEdit, onRemove, onOverride, onDragStart, onDr
block.appendChild(rm);
}
// LoRA strength slider (bottom center, semi-transparent)
if (!isDefault && entry.has_lora) {
const step = 0.05;
const clampVal = (v) => Math.round(Math.min(10, Math.max(-10, v)) / step) * step;
const formatVal = (v) => v.toFixed(2);
let current = clampVal(entry.strength != null ? entry.strength : 1);
const sliderBox = document.createElement("div");
css(sliderBox, "position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.5);border-radius:3px;padding:2px 6px;z-index:2;cursor:ew-resize;");
const valSpan = document.createElement("span");
valSpan.textContent = formatVal(current);
css(valSpan, "font-size:10px;color:#ccc;font-variant-numeric:tabular-nums;user-select:none;");
sliderBox.appendChild(valSpan);
function update(v) {
current = clampVal(v);
valSpan.textContent = formatVal(current);
entry.strength = current;
}
// Click to edit
valSpan.addEventListener("click", (e) => {
// Triangle button (bottom center) for options / LoRA
let triangleBtn = null;
if (!isDefault && (entry.has_lora || 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;");
triangleBtn.addEventListener("click", (e) => {
e.stopPropagation();
const input = document.createElement("input");
input.type = "number";
input.value = String(current);
input.step = String(step);
css(input, "font-size:10px;color:#ccc;background:transparent;border:none;outline:none;width:40px;text-align:center;");
valSpan.replaceWith(input);
input.focus();
input.select();
function commit() {
const v = parseFloat(input.value);
if (!isNaN(v)) update(v);
input.replaceWith(valSpan);
valSpan.textContent = formatVal(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 = formatVal(current); } });
});
// Drag to slide
let dragging = false;
let startX = 0;
let startVal = 0;
const pxPerStep = 4;
sliderBox.addEventListener("mousedown", (e) => {
if (e.target === valSpan) return;
e.preventDefault();
e.stopPropagation();
dragging = true;
startX = e.clientX;
startVal = current;
});
valSpan.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
dragging = true;
startX = e.clientX;
startVal = current;
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const deltaPx = e.clientX - startX;
const deltaSteps = Math.round(deltaPx / pxPerStep);
const newVal = clampVal(startVal + deltaSteps * step);
if (newVal !== current) {
current = newVal;
valSpan.textContent = formatVal(current);
entry.strength = current;
}
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
});
block.appendChild(sliderBox);
triangleBtn.addEventListener("dblclick", (e) => e.stopPropagation());
triangleBtn.addEventListener("mousedown", (e) => e.stopPropagation());
block.appendChild(triangleBtn);
}
block.addEventListener("dblclick", () => onEdit(idx));
@@ -1398,13 +1328,13 @@ function makeBlock({ entry, idx, onEdit, onRemove, onOverride, onDragStart, onDr
block.addEventListener("dragleave", () => { block.style.outline = ""; block.style.boxShadow = ""; });
block.addEventListener("drop", (e) => onDrop(e, idx, block));
return block;
return { block, triangleBtn };
}
// ---- Per-instance controls ----
function makeInstanceControls(block, entry, idx, onChanged) {
if (entry.inline) return;
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn) {
if (entry.inline) return { toggleOptionsPanel: () => {} };
// Options panel (hidden by default)
const panel = document.createElement("div");
@@ -1414,6 +1344,66 @@ function makeInstanceControls(block, entry, idx, onChanged) {
block.appendChild(panel);
let optionsLoaded = false;
let hasOptions = false;
function rebuildPanel() {
panel.replaceChildren();
// LoRA strength slider at top of panel
if (entry.has_lora) {
const sliderRow = document.createElement("div");
css(sliderRow, "padding:2px 0;");
const strSlider = makeComfyValueSlider(entry.strength != null ? entry.strength : 1.0, -10, 10, 0.05, (v) => {
entry.strength = v;
onChanged();
});
sliderRow.appendChild(strSlider);
panel.appendChild(sliderRow);
}
if (!hasOptions || !Object.keys(hasOptions).length) {
const empty = document.createElement("div");
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
empty.textContent = "no option groups";
panel.appendChild(empty);
} else {
for (const group of Object.keys(hasOptions)) {
const row = document.createElement("div");
css(row, "display:flex;gap:2px;align-items:center;");
const gLabel = document.createElement("span");
gLabel.textContent = group + ":";
css(gLabel, "font-size:9px;opacity:0.7;white-space:nowrap;");
row.appendChild(gLabel);
const sel = document.createElement("select");
css(sel, "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:2px;font-size:9px;padding:0 2px;flex:1;min-width:0;");
const noneOpt = document.createElement("option");
noneOpt.value = "";
noneOpt.textContent = "-";
sel.appendChild(noneOpt);
for (const ch of hasOptions[group]) {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
if ((entry.option || {})[group] === ch) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener("change", () => {
if (sel.value) {
entry.option = entry.option || {};
entry.option[group] = sel.value;
} else {
if (entry.option) delete entry.option[group];
}
onChanged();
});
row.appendChild(sel);
panel.appendChild(row);
}
}
}
async function toggleOptionsPanel() {
if (panel.style.display === "flex") {
@@ -1432,50 +1422,8 @@ function makeInstanceControls(block, entry, idx, onChanged) {
try {
const options = await fetchFlakeMeta(entry.name);
optionsLoaded = true;
panel.replaceChildren();
if (!Object.keys(options).length) {
const empty = document.createElement("div");
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
empty.textContent = "no option groups";
panel.appendChild(empty);
} else {
for (const group of Object.keys(options)) {
const row = document.createElement("div");
css(row, "display:flex;gap:2px;align-items:center;");
const gLabel = document.createElement("span");
gLabel.textContent = group + ":";
css(gLabel, "font-size:9px;opacity:0.7;white-space:nowrap;");
row.appendChild(gLabel);
const sel = document.createElement("select");
css(sel, "background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:2px;font-size:9px;padding:0 2px;flex:1;min-width:0;");
const noneOpt = document.createElement("option");
noneOpt.value = "";
noneOpt.textContent = "-";
sel.appendChild(noneOpt);
for (const ch of options[group]) {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
if ((entry.option || {})[group] === ch) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener("change", () => {
if (sel.value) {
entry.option = entry.option || {};
entry.option[group] = sel.value;
} else {
if (entry.option) delete entry.option[group];
}
onChanged();
});
row.appendChild(sel);
panel.appendChild(row);
}
}
hasOptions = options;
rebuildPanel();
} catch {
panel.replaceChildren();
const err = document.createElement("div");
@@ -1486,11 +1434,20 @@ function makeInstanceControls(block, entry, idx, onChanged) {
}
}
// Right-click or long-press to open options panel
// Right-click to open options panel
block.addEventListener("contextmenu", (e) => {
e.preventDefault();
toggleOptionsPanel();
});
// Triangle button click
if (triangleBtn) {
triangleBtn.addEventListener("click", () => {
toggleOptionsPanel();
});
}
return { toggleOptionsPanel };
}
function makeAddBlock({ onNew, onLoad }) {
@@ -1604,7 +1561,7 @@ function setupFlakeWidget(node) {
indicator.remove();
}
for (let i = 0; i < entries.length; i++) {
const blk = makeBlock({
const { block: blk, triangleBtn } = makeBlock({
entry: entries[i],
idx: i,
onEdit: handleEdit,
@@ -1637,12 +1594,12 @@ function setupFlakeWidget(node) {
onDragEnd: (el) => {
el.style.opacity = "";
dragSrcIdx = null;
for (const blk of grid.querySelectorAll("[data-flake-block]")) {
blk.style.boxShadow = "";
for (const b of grid.querySelectorAll("[data-flake-block]")) {
b.style.boxShadow = "";
}
},
});
makeInstanceControls(blk, entries[i], i, () => writeEntries(entries));
makeInstanceControls(blk, entries[i], i, () => writeEntries(entries), triangleBtn);
grid.appendChild(blk);
}
if (grid._addBlock) grid.appendChild(grid._addBlock);