306 lines
13 KiB
JavaScript
306 lines
13 KiB
JavaScript
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);
|
||
}
|
||
}); |