Files
Danchi93-ComfyUI-Workflow-T…/multi_lora_loader.js
T
2026-06-26 01:17:54 +08:00

306 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
const ROW_H = 30;
const PAD = 10;
const HEADER_H = 22;
const STRENGTH_W = 62;
const TOGGLE_W = 32; // 给胶囊留足够宽度
const DEL_W = ROW_H;
const NOTE_RATIO = 0.22;
const DROPDOWN_ITEM_H = 24;
const DROPDOWN_MAX = 12;
const NODE_MIN_W = 500;
const GAP = 5;
// 胶囊尺寸:严格小于TOGGLE_W
const TOG_W = 28, TOG_H = 16;
function colWidths(totalW) {
const avail = totalW - PAD * 2 - TOGGLE_W - STRENGTH_W - DEL_W - 4 * GAP;
const noteW = Math.floor(avail * NOTE_RATIO);
return [TOGGLE_W, avail - noteW, STRENGTH_W, noteW, DEL_W];
}
class DomDropdown {
constructor() {
this.el = document.createElement("div");
Object.assign(this.el.style, {
position: "fixed", zIndex: "99999", background: "#1c1c1c",
border: "1px solid #4a8a4a", borderRadius: "6px", overflowY: "auto",
boxShadow: "0 6px 20px rgba(0,0,0,0.8)", display: "none",
maxHeight: `${DROPDOWN_MAX * DROPDOWN_ITEM_H}px`,
});
document.body.appendChild(this.el);
this._onOutside = (e) => { if (!this.el.contains(e.target)) this.hide(); };
}
show(items, selectedItem, anchorRect, onSelect) {
this.el.innerHTML = "";
items.forEach(item => {
const div = document.createElement("div");
div.textContent = item === "None" ? "— None —" :
item.replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop();
Object.assign(div.style, {
padding: "5px 12px", cursor: "pointer", fontSize: "12px",
color: item === selectedItem ? "#7eb8f7" : "#ccc",
background: item === selectedItem ? "#1a3a5a" : "transparent",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
});
div.addEventListener("mouseover", () => div.style.background = "#2a4a2a");
div.addEventListener("mouseout", () => div.style.background = item === selectedItem ? "#1a3a5a" : "transparent");
div.addEventListener("mousedown", (e) => { e.stopPropagation(); onSelect(item); this.hide(); });
this.el.appendChild(div);
});
this.el.style.display = "block";
this.el.style.width = anchorRect.width + "px";
this.el.style.left = anchorRect.left + "px";
const spaceBelow = window.innerHeight - anchorRect.bottom;
const menuH = Math.min(items.length, DROPDOWN_MAX) * DROPDOWN_ITEM_H;
if (spaceBelow >= menuH || spaceBelow > window.innerHeight / 2) {
this.el.style.top = anchorRect.bottom + "px"; this.el.style.bottom = "auto";
} else {
this.el.style.bottom = (window.innerHeight - anchorRect.top) + "px"; this.el.style.top = "auto";
}
const selIdx = items.indexOf(selectedItem);
if (selIdx > 0) this.el.scrollTop = Math.max(0, selIdx - 3) * DROPDOWN_ITEM_H;
setTimeout(() => {
document.addEventListener("mousedown", this._onOutside, true);
document.addEventListener("pointerdown", this._onOutside, true);
}, 0);
}
hide() {
if (this.el.style.display === "none") return;
this.el.style.display = "none";
document.removeEventListener("mousedown", this._onOutside, true);
document.removeEventListener("pointerdown", this._onOutside, true);
app.canvas?.setDirty(true);
}
}
const domDropdown = new DomDropdown();
function parseRows(val) {
try {
const rows = JSON.parse(val || "[]");
if (Array.isArray(rows) && rows.length) return rows;
} catch {}
return null;
}
function defaultRows() {
return [{ enabled: true, lora: "None", strength: 1.0, note: "" }];
}
function drawBox(ctx, x, y, w, h, r, fill, stroke) {
ctx.beginPath(); ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
function makeHeaderWidget() {
return {
name: "__lora_header", type: "custom_lora_header",
value: null, serialize: false,
computeSize(ww) { return [ww, HEADER_H]; },
draw(ctx, node, ww, y) {
const cols = colWidths(ww);
let x = PAD;
ctx.fillStyle = "#606060";
ctx.font = "10px sans-serif";
ctx.textBaseline = "middle";
// 修复1:最后一列改回"删除"
["On/Off", "LoRA", "Strength", "Note", "Del"].forEach((label, i) => {
ctx.textAlign = "center";
ctx.fillText(label, x + cols[i] / 2, y + HEADER_H / 2);
x += cols[i] + GAP;
});
},
mouse() { return false; }
};
}
function makeLoraRowWidget(node, row, rowIndex, loraList, onDelete, onchange) {
return {
name: `__lora_row_${rowIndex}`, type: "custom_lora_row",
value: null, serialize: false,
computeSize(ww) { return [ww, ROW_H + 6]; },
draw(ctx, node, ww, y) {
this._lastY = y; this._lastW = ww;
const cols = colWidths(ww);
const ry = y + 3, rh = ROW_H;
drawBox(ctx, PAD, ry, ww - PAD * 2, rh, 6,
row.enabled ? "#1a2a1a" : "#271818",
row.enabled ? "#2d5a2d" : "#5a2d2d");
let x = PAD;
// 修复2:胶囊严格居中在TOGGLE_W列内,不超出
const tx = x + (cols[0] - TOG_W) / 2;
const ty = ry + (rh - TOG_H) / 2;
drawBox(ctx, tx, ty, TOG_W, TOG_H, TOG_H / 2,
row.enabled ? "#2e7d32" : "#424242",
row.enabled ? "#43a047" : "#555");
const knobR = TOG_H / 2 - 2;
const knobX = row.enabled ? tx + TOG_W - knobR - 2 : tx + knobR + 2;
ctx.beginPath(); ctx.arc(knobX, ty + TOG_H / 2, knobR, 0, Math.PI * 2);
ctx.fillStyle = "#fff"; ctx.fill();
x += cols[0] + GAP;
// LoRA下拉
const lx = x, lw = cols[1];
drawBox(ctx, lx, ry + 4, lw, rh - 8, 4, "#252525", "#3a3a3a");
ctx.save(); ctx.rect(lx + 6, ry, lw - 20, rh + 6); ctx.clip();
ctx.fillStyle = row.lora === "None" ? "#555" : "#ddd";
ctx.font = "11.5px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(
row.lora === "None" ? "Select LoRA..." :
row.lora.replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop(),
lx + 7, ry + rh / 2);
ctx.restore();
ctx.fillStyle = "#666"; ctx.font = "10px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText("▾", lx + lw - 5, ry + rh / 2);
x += lw + GAP;
// 权重
drawBox(ctx, x, ry + 4, cols[2], rh - 8, 4, "#252525", "#3a3a3a");
ctx.fillStyle = row.strength === 1.0 ? "#aaa" : "#7ec8e3";
ctx.font = "12px 'Courier New', monospace";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(row.strength.toFixed(2), x + cols[2] / 2, ry + rh / 2);
x += cols[2] + GAP;
// 备注
drawBox(ctx, x, ry + 4, cols[3], rh - 8, 4, "#1e1e28", "#35354a");
ctx.save(); ctx.rect(x + 4, ry, cols[3] - 8, rh + 6); ctx.clip();
ctx.fillStyle = row.note ? "#aaa" : "#444";
ctx.font = "11px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(row.note || "Note...", x + 6, ry + rh / 2);
ctx.restore();
x += cols[3] + GAP;
// 删除
const bs = rh - 8, bx = x + (cols[4] - bs) / 2, by = ry + 4;
drawBox(ctx, bx, by, bs, bs, 5, "#6a0000", "#c0392b");
ctx.fillStyle = "#ff6b6b";
ctx.font = `bold ${Math.floor(bs * 0.58)}px sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText("✕", bx + bs / 2, by + bs / 2);
},
mouse(e, pos, node) {
if (e.type !== "pointerdown") return false;
const [mx, my] = pos;
const ww = this._lastW || node.size[0];
const y = this._lastY || 0;
if (my < y || my > y + ROW_H + 6) return false;
const cols = colWidths(ww);
let x = PAD;
if (mx >= x && mx < x + cols[0]) { row.enabled = !row.enabled; onchange(); return true; }
x += cols[0] + GAP;
if (mx >= x && mx < x + cols[1]) {
const rect = app.canvas.canvas.getBoundingClientRect();
const scale = app.canvas.ds?.scale ?? 1;
const off = app.canvas.ds?.offset ?? [0, 0];
const sx = rect.left + (node.pos[0] + x) * scale + off[0] * scale;
const sy = rect.top + (node.pos[1] + y + ROW_H) * scale + off[1] * scale;
domDropdown.show(loraList, row.lora,
{ left: sx, top: sy, bottom: sy, width: cols[1] * scale },
(sel) => { row.lora = sel; onchange(); });
return true;
}
x += cols[1] + GAP;
if (mx >= x && mx < x + cols[2]) {
app.canvas.prompt("Strength", row.strength.toFixed(2), (val) => {
const n = parseFloat(val);
if (!isNaN(n)) { row.strength = Math.round(n * 100) / 100; onchange(); }
}, e);
return true;
}
x += cols[2] + GAP;
if (mx >= x && mx < x + cols[3]) {
app.canvas.prompt("Note", row.note, (val) => { row.note = val; onchange(); }, e);
return true;
}
x += cols[3] + GAP;
if (mx >= x && mx < x + cols[4]) { onDelete(); return true; }
return false;
}
};
}
// 修复3:自定义添加按钮widget,文字真正垂直居中
function makeAddButtonWidget(onClick) {
const BTN_H = 28;
return {
name: "__lora_add_btn", type: "custom_lora_add",
value: null, serialize: false,
computeSize(ww) { return [ww, BTN_H + 8]; },
draw(ctx, node, ww, y) {
this._lastY = y;
drawBox(ctx, PAD, y + 4, ww - PAD * 2, BTN_H, 6, "#1e1e1e", "#404040");
ctx.fillStyle = "#7c6cf0";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle"; // 真正居中
ctx.fillText("+ Add LoRA", ww / 2, y + 4 + BTN_H / 2);
},
mouse(e, pos) {
if (e.type !== "pointerdown") return false;
const [, my] = pos;
const y = this._lastY || 0;
if (my >= y + 4 && my <= y + 4 + BTN_H) { onClick(); return true; }
return false;
}
};
}
app.registerExtension({
name: "MultiLoraLoader",
async nodeCreated(node) {
if (node.comfyClass !== "MultiLoraLoader") return;
const stackWidget = node.widgets?.find(w => w.name === "lora_stack");
if (stackWidget) {
stackWidget.computeSize = () => [0, -4];
stackWidget.draw = () => {};
}
if (node.size[0] < NODE_MIN_W) node.size[0] = NODE_MIN_W;
let loraList = ["None"];
try {
const resp = await fetch("/object_info/LoraLoader");
const data = await resp.json();
const vals = data?.LoraLoader?.input?.required?.lora_name?.[0];
if (Array.isArray(vals)) loraList = ["None", ...vals];
} catch (e) {}
node._rebuild = (rows) => {
node._loraRows = rows;
node.widgets = (node.widgets || []).filter(w =>
w.name === "model" || w.name === "lora_stack"
);
const sw = node.widgets?.find(w => w.name === "lora_stack");
if (sw) { sw.computeSize = () => [0, -4]; sw.draw = () => {}; }
node.addCustomWidget(makeHeaderWidget());
rows.forEach((row, i) => {
node.addCustomWidget(makeLoraRowWidget(node, row, i, loraList,
() => { rows.splice(i, 1); node._rebuild(rows); },
() => { if (sw) sw.value = JSON.stringify(rows); app.canvas?.setDirty(true, true); }
));
});
// 用自定义widget替代原生button
node.addCustomWidget(makeAddButtonWidget(() => {
rows.push({ enabled: true, lora: "None", strength: 1.0, note: "" });
node._rebuild(rows);
}));
if (stackWidget) stackWidget.value = JSON.stringify(rows);
node.setSize([node.size[0], node.computeSize()[1]]);
app.canvas?.setDirty(true, true);
};
const initialRows = parseRows(stackWidget?.value) ?? defaultRows();
node._rebuild(initialRows);
}
});