Make the canvas lora rows work under Nodes 2.0; drop the DOM rewrite

power_lora_dom.js existed because a canvas custom widget could not repaint
itself inside core's WidgetLegacy wrapper, so toggling a lora or nudging a
strength changed the value with no visible feedback. Frontend 1.48.7's
WidgetLegacy.vue now assigns `widget.triggerDraw`, documented in litegraph's
widget types as the compatibility hook for "widgets implementing the draw
method when displayed in non-canvas renderers". That is the missing piece, so
the parallel 528-line DOM implementation is no longer needed.

- mxd_nodes2.js: add redrawWidget()/redrawWidgets(). Optional-call triggerDraw,
  so both are no-ops in classic mode and on frontends that lack it.
- MxdBaseWidget.mouse(): repaint after every pointer event. Also fix its
  "pointer left the widget" test, which compared node-relative bounds against
  row-relative coordinates under Nodes 2.0 and swallowed clicks.
- power_lora_base/loader: repaint on the mutations that happen outside a
  pointer handler — slot menu, toggle-all, async lora info, Show Strengths.
- Delete power_lora_dom.js, mxd_power_lora.css, and the MXD.PowerLora.RowStyle
  setting. None of them were ever published, so no workflow or setting breaks.

No Python touched; harness still reports 65 nodes identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Maxed-Out-99
2026-08-08 06:51:40 -07:00
co-authored by Claude Opus 5
parent 0836bfd573
commit 48981e19d3
8 changed files with 113 additions and 794 deletions
+3 -4
View File
@@ -36,10 +36,9 @@ enforces this.
`beforeRegisterNodeDef` (names must match `NODE_CLASS_MAPPINGS` keys).
- `power_lora_base.js` — shared base for the lora loader: node machinery,
header row, single-strength row widget. `power_lora_loader.js` adds the
dual model/clip strength mode. `power_lora_dom.js` is a parallel DOM-widget
implementation of the same rows for Nodes 2.0 — see the note in CLAUDE.md,
it is unfinished. Serialization shapes are frozen (see the CLAUDE.md
contract).
dual model/clip strength mode. Rows are canvas-drawn in both classic and
Nodes 2.0; `mxd_nodes2.js` supplies the width and repaint shims that make
that work. Serialization shapes are frozen (see the CLAUDE.md contract).
- `better_combos.js` — folder-tree/grid combo display for the MXD latent
loaders (adapted from pysssss; scoped to MXD nodes only; keeps its BOM).
- `run_folder.js` — wraps `app.queuePrompt` for batch folder runs; uses
-1
View File
@@ -18,7 +18,6 @@ import './lib/mxd_smart_search.js';
import './nodes/better_combos.js';
import './nodes/image_comparer.js';
import './vendor/zip_loader/zip_loader.js';
import './nodes/power_lora_dom.js';
import './nodes/power_lora_base.js';
import './nodes/power_lora_loader.js';
import './nodes/ltx_sampler.js';
+28
View File
@@ -27,6 +27,8 @@
// Deliberately, with Nodes 2.0 OFF it returns `node.size[0]` — identical to the
// pre-Nodes-2.0 behavior — so turning the setting off is a full revert and
// classic rendering can never be affected by this module.
//
// Repainting: see redrawWidgets() below.
import { app } from "../../../scripts/app.js";
const SETTING_ID = "Comfy.VueNodes.Enabled";
@@ -64,3 +66,29 @@ export function nodeDrawWidth(node, w) {
}
return node?.size?.[0] ?? 0;
}
// Repaint a canvas-drawn custom widget after code changed its value directly.
//
// Classic mode repaints the whole node canvas, so setDirtyCanvas() is enough.
// Under Nodes 2.0 each legacy widget lives in its OWN <canvas> inside core's
// WidgetLegacy component, and the graph canvas being dirty says nothing about
// that private canvas. Core's fix is `widget.triggerDraw`, which it documents as
// "compatibility method for widgets implementing the draw method when displayed
// in non-canvas renderers ... set by the current renderer implementation".
// Core only wires it into `widget.callback`; our widgets mutate `this.value`
// directly, so we call it ourselves.
//
// It is absent on classic frontends and on older ones that predate it, hence
// the optional call — this is a no-op there, which is exactly right.
export function redrawWidget(widget) {
widget?.triggerDraw?.();
}
// Repaint every widget on a node. Used when one interaction changes several
// rows at once (toggle-all, reordering, a row being removed).
export function redrawWidgets(node) {
for (const widget of node?.widgets || []) {
widget.triggerDraw?.();
}
node?.setDirtyCanvas?.(true, true);
}
-214
View File
@@ -1,214 +0,0 @@
/* HTML (DOM-widget) rows for the Power Lora Loader nodes.
*
* Used only when the HTML row style is active (see power_lora_dom.js); the
* classic canvas-painted rows do not load or need this file. Colors come from
* ComfyUI's theme variables so the rows track light/dark and custom palettes,
* with fallbacks for older frontends that lack a given variable. */
.mxd-lora-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 0 2px;
font-family: inherit;
font-size: 11px;
color: var(--input-text, #ddd);
/* Nothing here should trigger the canvas' own drag/pan handling. */
touch-action: none;
}
.mxd-lora-row.mxd-lora-off > *:not(.mxd-lora-toggle) {
opacity: 0.4;
}
/* Shared look for every clickable part of a row. */
.mxd-lora-row button,
.mxd-lora-row input {
font-family: inherit;
font-size: inherit;
color: inherit;
background: var(--comfy-input-bg, #222);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
padding: 0;
margin: 0;
cursor: pointer;
box-sizing: border-box;
min-width: 0;
}
.mxd-lora-row button:hover,
.mxd-lora-row input:hover {
border-color: var(--p-primary-color, #66a);
}
.mxd-lora-row button:focus-visible,
.mxd-lora-row input:focus-visible {
outline: 1px solid var(--p-primary-color, #66a);
outline-offset: 0;
}
/* On/off switch --------------------------------------------------------- */
.mxd-lora-toggle {
flex: 0 0 auto;
position: relative;
width: 22px;
height: 12px;
border-radius: 6px !important;
background: var(--comfy-input-bg, #222);
}
.mxd-lora-toggle::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color, #666);
transition: transform 0.12s ease, background-color 0.12s ease;
}
.mxd-lora-toggle[aria-checked="true"] {
background: color-mix(in srgb, var(--p-primary-color, #66a) 35%, transparent);
border-color: var(--p-primary-color, #66a);
}
.mxd-lora-toggle[aria-checked="true"]::after {
transform: translateX(10px);
background: var(--p-primary-color, #8ae);
}
/* Indeterminate state, used by the header's toggle-all. */
.mxd-lora-toggle[aria-checked="mixed"]::after {
transform: translateX(5px);
background: var(--p-primary-color, #8ae);
opacity: 0.6;
}
/* Lora name ------------------------------------------------------------- */
.mxd-lora-name {
flex: 1 1 auto;
height: 16px;
text-align: left;
padding: 0 4px !important;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl; /* keep the filename's tail (the distinctive part) visible */
}
.mxd-lora-name > span {
direction: ltr;
unicode-bidi: embed;
}
/* Small square icon buttons (info / remove) ----------------------------- */
.mxd-lora-icon {
flex: 0 0 auto;
width: 15px;
height: 15px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
.mxd-lora-remove {
color: #d88;
}
.mxd-lora-remove:hover {
border-color: #d88;
}
/* Strength stepper ------------------------------------------------------ */
.mxd-lora-strength {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 1px;
}
.mxd-lora-strength button {
width: 12px;
height: 15px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
.mxd-lora-strength input {
width: 38px;
height: 15px;
text-align: center;
/* Horizontal drag scrubs the value; a plain click still types. */
cursor: ew-resize;
}
.mxd-lora-strength input.mxd-lora-out-of-range {
color: #c66;
}
/* Header row ------------------------------------------------------------ */
.mxd-lora-header {
font-size: 10px;
opacity: 0.75;
}
.mxd-lora-header .mxd-lora-header-label {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Column captions sit above the strength steppers, so they must match the
* stepper's total width (12 + 1 + 38 + 1 + 12) to stay aligned. */
.mxd-lora-header .mxd-lora-header-col {
flex: 0 0 auto;
width: 64px;
text-align: center;
}
/* Occupies the space of a row's info/remove button without drawing anything. */
.mxd-lora-header .mxd-lora-spacer {
visibility: hidden;
border: 0 !important;
background: none !important;
}
/* "+ Add Lora" ---------------------------------------------------------- */
.mxd-lora-add {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 11px;
color: var(--input-text, #ddd);
background: var(--comfy-input-bg, #222);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
cursor: pointer;
touch-action: none;
}
.mxd-lora-add:hover {
border-color: var(--p-primary-color, #66a);
}
+26 -6
View File
@@ -1,5 +1,6 @@
import { app } from "../../../scripts/app.js";
import { drawNodeWidget, drawWidgetButton, fitString, isLowQuality } from "./mxd_utils_canvas.js";
import { isNodes2Enabled, nodeDrawWidth } from "./mxd_nodes2.js";
export function drawLabelAndValue(ctx, label, value, width, posY, height, options) {
var _a;
const outerMargin = 15;
@@ -46,6 +47,12 @@ export class MxdBaseWidget {
mouse(event, pos, node) {
var _a, _b, _c;
const canvas = app.canvas;
// Any pointer interaction can change what this widget looks like — a
// pressed button, a flipped toggle, a new number. Under Nodes 2.0 the
// graph canvas going dirty does not repaint this widget's own canvas,
// so ask core's renderer for a redraw once the handlers below have run.
// No-op in classic mode and on frontends without `triggerDraw`.
const redraw = () => this.triggerDraw?.();
if (event.type == "pointerdown") {
this.mouseDowned = [...pos];
this.isMouseDownedAndOver = true;
@@ -67,7 +74,9 @@ export class MxdBaseWidget {
part.wasMouseClickedAndIsOver = true;
}
}
return (_a = this.onMouseDown(event, pos, node)) !== null && _a !== void 0 ? _a : anyHandled;
const downResult = (_a = this.onMouseDown(event, pos, node)) !== null && _a !== void 0 ? _a : anyHandled;
redraw();
return downResult;
}
if (event.type == "pointerup") {
if (!this.mouseDowned)
@@ -94,15 +103,24 @@ export class MxdBaseWidget {
const thisHandled = this.onMouseClick(event, pos, node);
anyHandled = anyHandled || thisHandled == true;
}
return (_b = this.onMouseUp(event, pos, node)) !== null && _b !== void 0 ? _b : anyHandled;
const upResult = (_b = this.onMouseUp(event, pos, node)) !== null && _b !== void 0 ? _b : anyHandled;
redraw();
return upResult;
}
if (event.type == "pointermove") {
this.isMouseDownedAndOver = !!this.mouseDowned;
// Under Nodes 2.0 `pos` is relative to this widget's own row canvas,
// whose top is 0 and whose width core stores in `this.width`. In
// classic mode `pos` is node-relative, so the row top is `last_y`
// and the surface is the whole node. Using the wrong pair here makes
// the widget think the pointer left it and swallows the click.
const surfaceWidth = nodeDrawWidth(node, this.width);
const rowTop = isNodes2Enabled() ? 0 : this.last_y;
if (this.mouseDowned &&
(pos[0] < 15 ||
pos[0] > node.size[0] - 15 ||
pos[1] < this.last_y ||
pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)) {
pos[0] > surfaceWidth - 15 ||
pos[1] < rowTop ||
pos[1] > rowTop + LiteGraph.NODE_WIDGET_HEIGHT)) {
this.isMouseDownedAndOver = false;
}
for (const part of Object.values(this.hitAreas)) {
@@ -113,7 +131,9 @@ export class MxdBaseWidget {
part.wasMouseClickedAndIsOver = this.clickWasWithinBounds(pos, part.bounds);
}
}
return (_c = this.onMouseMove(event, pos, node)) !== null && _c !== void 0 ? _c : true;
const moveResult = (_c = this.onMouseMove(event, pos, node)) !== null && _c !== void 0 ? _c : true;
redraw();
return moveResult;
}
return false;
}
+44 -37
View File
@@ -31,18 +31,11 @@ import {
MxdDividerWidget,
} from "../lib/mxd_utils_widgets.js";
import { mxdApi } from "../lib/mxd_api.js";
import { nodeDrawWidth } from "../lib/mxd_nodes2.js";
import { nodeDrawWidth, redrawWidget, redrawWidgets } from "../lib/mxd_nodes2.js";
import { showLoraChooser } from "../lib/mxd_utils_menu.js";
import { moveArrayItem, removeArrayItem } from "../lib/mxd_shared_utils.js";
import { MxdLoraInfoDialog } from "../lib/mxd_dialog_info.js";
import { LORA_INFO_SERVICE } from "../lib/mxd_model_info_service.js";
import {
addDomAddLoraButton,
addDomHeaderRow,
addDomLoraRow,
refreshPowerLoraDom,
usePowerLoraDom,
} from "./power_lora_dom.js";
export const PROP_LABEL_SHOW_STRENGTHS = "Show Strengths";
export const PROP_LABEL_SHOW_STRENGTHS_STATIC = `@${PROP_LABEL_SHOW_STRENGTHS}`;
@@ -52,9 +45,6 @@ export const PROP_VALUE_SHOW_STRENGTHS_SEPARATE = "Separate Model & Clip";
export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
// Subclasses set this to their canvas-drawn lora row widget class.
static loraWidgetClass = null;
// Whether this node's rows can show separate model/clip strengths. Only
// consulted by the HTML rows; the canvas rows read the property directly.
static loraRowSupportsDual = false;
constructor(title = new.target.title, loggerName = "[Power Lora Loader]") {
super(title);
@@ -62,9 +52,6 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
this.logger = mxdRuntime.newLogSession(loggerName);
this.loraWidgetsCounter = 0;
this.widgetButtonSpacer = null;
// Decided once per node so a node never mixes canvas and HTML rows if the
// setting changes while it exists. Both implementations serialize the same.
this.usesDomRows = usePowerLoraDom();
mxdApi.getLoras();
@@ -172,8 +159,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
this.setDirtyCanvas(true, true);
}
// True when the node shows separate model & clip strength columns. Nodes
// without the "Show Strengths" property (e.g. the LTX2 loader) never do.
// True when the node shows separate model & clip strength columns.
isShowingSeparateStrengths() {
return this.properties?.[PROP_LABEL_SHOW_STRENGTHS] === PROP_VALUE_SHOW_STRENGTHS_SEPARATE;
}
@@ -181,9 +167,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
addNewLoraWidget(lora) {
this.loraWidgetsCounter++;
const name = "lora_" + this.loraWidgetsCounter;
const widget = this.usesDomRows
? addDomLoraRow(this, name, { dual: this.constructor.loraRowSupportsDual })
: this.addCustomWidget(new this.constructor.loraWidgetClass(name));
const widget = this.addCustomWidget(new this.constructor.loraWidgetClass(name));
if (lora) widget.setLora(lora);
if (this.widgetButtonSpacer) {
moveArrayItem(this.widgets, widget, this.widgets.indexOf(this.widgetButtonSpacer));
@@ -201,11 +185,9 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
moveArrayItem(this.widgets, this.addHeaderRowWidget(), 1);
}
// The toggle-all / column-caption row, in whichever style this node uses.
// The toggle-all / column-caption row.
addHeaderRowWidget() {
return this.usesDomRows
? addDomHeaderRow(this, { dual: this.constructor.loraRowSupportsDual })
: this.addCustomWidget(new PowerLoraLoaderHeaderWidget());
return this.addCustomWidget(new PowerLoraLoaderHeaderWidget());
}
addNonLoraWidgets() {
@@ -215,11 +197,6 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
new MxdDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 }),
);
if (this.usesDomRows) {
addDomAddLoraButton(this, (value) => this.addNewLoraWidget(value));
return;
}
this.addCustomWidget(
new MxdBetterButtonWidget("+ Add Lora", (event, pos, node) => {
mxdApi.getLoras().then((lorasDetails) => {
@@ -231,7 +208,8 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
this.addNewLoraWidget(value);
const computed = this.computeSize();
this.size[1] = Math.max(this.size?.[1] ?? 15, computed[1]);
this.setDirtyCanvas(true, true);
// A new row shifts every row below it, so repaint them all.
redrawWidgets(this);
}
},
null,
@@ -278,7 +256,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
content: `${widget.value.on ? "Disable" : "Enable"}`,
callback: () => {
widget.value.on = !widget.value.on;
refreshPowerLoraDom(this);
redrawWidgets(this);
},
},
{
@@ -286,7 +264,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
disabled: !canMoveUp,
callback: () => {
moveArrayItem(this.widgets, widget, index - 1);
refreshPowerLoraDom(this);
redrawWidgets(this);
},
},
{
@@ -294,7 +272,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
disabled: !canMoveDown,
callback: () => {
moveArrayItem(this.widgets, widget, index + 1);
refreshPowerLoraDom(this);
redrawWidgets(this);
},
},
{
@@ -303,7 +281,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
// removeWidget (unlike removeArrayItem) fires onRemove, which the
// HTML rows need in order to detach their element.
this.removeWidget(widget);
refreshPowerLoraDom(this);
redrawWidgets(this);
},
},
];
@@ -320,6 +298,16 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
mxdApi.getLoras(true);
}
// Flipping "Show Strengths" changes what every row draws. Classic mode
// repaints on the next canvas frame anyway; Nodes 2.0 needs to be told.
onPropertyChanged(name, value, prevValue) {
const handled = super.onPropertyChanged?.(name, value, prevValue);
if (name === PROP_LABEL_SHOW_STRENGTHS) {
redrawWidgets(this);
}
return handled;
}
hasLoraWidgets() {
return !!this.widgets?.find((w) => w.name?.startsWith("lora_"));
}
@@ -346,7 +334,7 @@ export class MxdPowerLoraLoaderBase extends MxdBaseServerNode {
widget.value.on = toggledTo;
}
}
refreshPowerLoraDom(this);
redrawWidgets(this);
}
static setUp(comfyClass, nodeData) {
@@ -460,6 +448,7 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
setLora(lora) {
this._value.lora = lora;
this.getLoraInfo();
redrawWidget(this);
}
// Shared drawing pieces -----------------------------------------------
@@ -584,6 +573,8 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
onToggleDown(event, pos, node) {
this.value.on = !this.value.on;
// The header's toggle-all reflects every row, so repaint the whole node.
redrawWidgets(node);
this.cancelMouseDown();
return true;
}
@@ -596,7 +587,7 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
onRemoveDown(event, pos, node) {
removeArrayItem(node.widgets, this);
node.setDirtyCanvas(true, true);
redrawWidgets(node);
this.cancelMouseDown();
return true;
}
@@ -609,6 +600,7 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
this.getLoraInfo();
}
node.setDirtyCanvas(true, true);
redrawWidget(this);
});
this.cancelMouseDown();
}
@@ -625,13 +617,22 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
if (event.deltaX) {
this.haveMouseMovedStrength = true;
this.value.strength = (this.value.strength ?? 1) + event.deltaX * 0.05;
redrawWidget(this);
}
}
onStrengthValUp(event, pos, node) {
if (this.haveMouseMovedStrength) return;
const canvas = app.canvas;
canvas.prompt("Value", this.value.strength, (v) => (this.value.strength = Number(v)), event);
canvas.prompt(
"Value",
this.value.strength,
(v) => {
this.value.strength = Number(v);
redrawWidget(this);
},
event,
);
}
onMouseUp(event, pos, node) {
@@ -655,6 +656,7 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
let step = 0.05;
let strength = (this.value.strength ?? 1) + step * direction;
this.value.strength = Math.round(strength * 100) / 100;
redrawWidget(this);
}
getLoraInfo(force = false) {
@@ -665,7 +667,12 @@ export class PowerLoraBaseWidget extends MxdBaseWidget {
} else {
promise = Promise.resolve(null);
}
this.loraInfoPromise = promise.then((v) => (this.loraInfo = v));
// Arrives asynchronously and decides the out-of-range strength color.
this.loraInfoPromise = promise.then((v) => {
this.loraInfo = v;
redrawWidget(this);
return v;
});
}
return this.loraInfoPromise;
}
-528
View File
@@ -1,528 +0,0 @@
// HTML (DOM-widget) rows for the Power Lora Loader nodes.
//
// This file is a PARALLEL implementation of the row widgets that live in
// power_lora_base.js / power_lora_loader.js. Those
// paint themselves onto the LiteGraph canvas; these build real HTML elements and
// register them with `node.addDOMWidget()`. Nothing here modifies the canvas
// widgets — `usePowerLoraDom()` decides which of the two a node is built from,
// so the classic rows remain reachable and unchanged forever.
//
// Why an HTML version exists
// --------------------------
// Under ComfyUI's Nodes 2.0 (DOM node rendering), core hosts a canvas-drawing
// custom widget in its `WidgetLegacy` component, which only repaints that
// widget's private canvas when the widget's `callback` fires. Our canvas rows
// mutate `this.value` directly and rely on `node.setDirtyCanvas()`, which
// repaints the graph canvas but NOT the widget's private canvas — so toggling a
// lora or nudging a strength changed the value with no visible feedback. Real
// DOM elements sidestep the whole bridge: the browser renders them, and both
// classic ComfyUI and Nodes 2.0 mount DOM widgets natively (core uses the same
// API for every multiline prompt box).
//
// Serialization is deliberately IDENTICAL to the canvas rows: widget names stay
// `lora_N` and values stay `{on, lora, strength[, strengthTwo]}`. Saved
// workflows cannot tell which implementation drew them.
import { app } from "../../../scripts/app.js";
import { isNodes2Enabled } from "../lib/mxd_nodes2.js";
import { injectCss } from "../lib/mxd_shared_utils.js";
import { showLoraChooser } from "../lib/mxd_utils_menu.js";
import { MxdLoraInfoDialog } from "../lib/mxd_dialog_info.js";
import { LORA_INFO_SERVICE } from "../lib/mxd_model_info_service.js";
const CSS_HREF = new URL("../lib/mxd_power_lora.css", import.meta.url).pathname;
export const ROW_HEIGHT = 20;
export const HEADER_HEIGHT = 16;
export const BUTTON_HEIGHT = 22;
const STRENGTH_STEP = 0.05;
const SETTING_ROW_STYLE = "MXD.PowerLora.RowStyle";
const STYLE_AUTO = "Auto (follow Nodes 2.0)";
const STYLE_CLASSIC = "Classic canvas";
const STYLE_HTML = "HTML";
// Which row implementation new nodes should be built from.
// Auto keeps classic behavior whenever Nodes 2.0 is off, so turning Nodes 2.0
// off is a complete return to the original rendering.
export function usePowerLoraDom() {
let style = STYLE_AUTO;
try {
style = app?.extensionManager?.setting?.get(SETTING_ROW_STYLE) ?? STYLE_AUTO;
} catch (e) {
style = STYLE_AUTO;
}
if (style === STYLE_CLASSIC) return false;
if (style === STYLE_HTML) return true;
return isNodes2Enabled();
}
app.registerExtension({
name: "mxd.PowerLoraRowStyle",
settings: [
{
id: SETTING_ROW_STYLE,
category: ["MaxedOut", "Power Lora Loader", "Row style"],
name: "Power Lora Loader row style",
tooltip:
"How the Lora Loader MXD / LTX2 Lora Loader MXD rows are drawn. " +
"Auto uses HTML rows when ComfyUI's Nodes 2.0 is on and the original " +
"canvas rows when it is off. Reload the page after changing this.",
type: "combo",
options: [STYLE_AUTO, STYLE_CLASSIC, STYLE_HTML],
defaultValue: STYLE_AUTO,
},
],
});
// --- small helpers -------------------------------------------------------
function fmt(value) {
return Number(value ?? 0).toFixed(2);
}
function el(tag, className, parent) {
const node = document.createElement(tag);
if (className) node.className = className;
if (parent) parent.appendChild(node);
return node;
}
function button(className, parent, label) {
const b = el("button", className, parent);
b.type = "button";
if (label != null) b.textContent = label;
// Keep presses from reaching the canvas (node drag) or the Vue node body.
b.addEventListener("pointerdown", (e) => e.stopPropagation());
return b;
}
// Every DOM row on a node registers a refresher so that changes made elsewhere
// (toggle-all, the slot context menu, a workflow load) can repaint the row.
function registerRefresher(node, fn) {
if (!node.__mxdDomRefreshers) node.__mxdDomRefreshers = [];
node.__mxdDomRefreshers.push(fn);
return fn;
}
export function refreshPowerLoraDom(node) {
for (const fn of node?.__mxdDomRefreshers || []) {
try {
fn();
} catch (e) {
/* a broken row must not stop the rest from repainting */
}
}
}
// Grow the node to fit its widgets, matching what the canvas rows do.
function growNodeToFit(node) {
const computed = node.computeSize();
node.size = node.size || [0, 0];
node.size[1] = Math.max(node.size[1] ?? 15, computed[1]);
node.setDirtyCanvas(true, true);
}
// Registers `element` as a DOM widget and wires the shared plumbing:
// value <-> state, serialization, fixed row height, and element teardown.
//
// Serialization note: core saves workflows from `widget.value` (JSON-cloned)
// but builds prompts from `serializeValue()`. Every widget here keeps the same
// `value` its canvas counterpart had, and no widget opts out of serialization,
// so `widgets_values` comes out byte-identical either way — a workflow saved in
// one row style loads correctly in the other.
function addRowWidget(node, { name, element, height, getState, setState, serializeState }) {
injectCss(CSS_HREF);
const widget = node.addDOMWidget(name, "custom", element, {
hideOnZoom: true,
getValue: getState,
setValue: setState,
getMinHeight: () => height,
getMaxHeight: () => height,
getHeight: () => height,
});
// Hand back a plain snapshot, never the live proxy.
widget.serializeValue = serializeState ?? (() => ({ ...getState() }));
const priorOnRemove = widget.onRemove?.bind(widget);
widget.onRemove = () => {
priorOnRemove?.();
element.remove();
const list = node.__mxdDomRefreshers;
if (list && widget.__mxdRefresher) {
const i = list.indexOf(widget.__mxdRefresher);
if (i > -1) list.splice(i, 1);
}
};
return widget;
}
// Wraps the row's raw value object so that ANY external mutation — including
// `widget.value.on = false` from toggleAllLoras or the slot context menu —
// repaints the HTML. Without this, code written for the canvas rows would
// silently change state that the DOM never re-rendered.
function reactiveState(raw, onChange) {
return new Proxy(raw, {
set(target, prop, value) {
target[prop] = value;
onChange();
return true;
},
deleteProperty(target, prop) {
delete target[prop];
onChange();
return true;
},
});
}
// A `- value +` stepper with click-to-type and horizontal drag-to-scrub,
// mirroring the canvas rows' strength control.
function strengthControl(parent, { get, set, onCommit }) {
const wrap = el("div", "mxd-lora-strength", parent);
const dec = button(null, wrap, "‹");
const input = el("input", null, wrap);
const inc = button(null, wrap, "›");
input.type = "text";
input.spellcheck = false;
const render = () => {
if (document.activeElement !== input) {
input.value = fmt(get());
}
};
const step = (direction) => {
set(Math.round((Number(get() ?? 1) + STRENGTH_STEP * direction) * 100) / 100);
render();
onCommit?.();
};
dec.addEventListener("click", (e) => {
e.stopPropagation();
step(-1);
});
inc.addEventListener("click", (e) => {
e.stopPropagation();
step(1);
});
// Drag horizontally to scrub; a click that never moved focuses for typing.
let dragStartX = null;
let dragStartValue = 0;
let dragged = false;
input.addEventListener("pointerdown", (e) => {
e.stopPropagation();
if (document.activeElement === input) return; // already typing
e.preventDefault();
dragStartX = e.clientX;
dragStartValue = Number(get() ?? 1);
dragged = false;
input.setPointerCapture(e.pointerId);
});
input.addEventListener("pointermove", (e) => {
if (dragStartX == null) return;
const dx = e.clientX - dragStartX;
if (!dragged && Math.abs(dx) < 3) return;
dragged = true;
set(Math.round((dragStartValue + dx * STRENGTH_STEP) * 100) / 100);
render();
});
input.addEventListener("pointerup", (e) => {
if (dragStartX == null) return;
input.releasePointerCapture?.(e.pointerId);
dragStartX = null;
if (dragged) {
onCommit?.();
} else {
input.focus();
input.select();
}
});
const commitTyped = () => {
const parsed = Number.parseFloat(input.value);
if (Number.isFinite(parsed)) {
set(parsed);
}
render();
onCommit?.();
};
input.addEventListener("change", commitTyped);
input.addEventListener("blur", commitTyped);
input.addEventListener("keydown", (e) => {
e.stopPropagation(); // don't let the canvas' hotkeys steal typing
if (e.key === "Enter") {
input.blur();
} else if (e.key === "Escape") {
render();
input.blur();
}
});
return { render, input };
}
// --- lora row ------------------------------------------------------------
// One lora: [on/off] name [i] [x] [strength] (+ a second strength when `dual`).
// `dual` mirrors the "Show Strengths" property of Lora Loader MXD; the row
// re-reads it on every refresh so switching the property updates live.
export function addDomLoraRow(node, name, { dual = false } = {}) {
const raw = { on: true, lora: null, strength: 1 };
if (dual) raw.strengthTwo = null;
let loraInfo = null;
let loraInfoPromise = null;
const root = el("div", "mxd-lora-row");
const toggle = button("mxd-lora-toggle", root);
toggle.setAttribute("role", "switch");
const nameBtn = button("mxd-lora-name", root);
const nameText = el("span", null, nameBtn);
const infoBtn = button("mxd-lora-icon mxd-lora-info", root, "i");
const removeBtn = button("mxd-lora-icon mxd-lora-remove", root, "✕");
let widget = null;
const state = reactiveState(raw, () => render());
const isDualActive = () => dual && node.isShowingSeparateStrengths?.() === true;
const outOfRange = (value) => {
if (loraInfo?.strengthMax != null && value > loraInfo.strengthMax) return true;
if (loraInfo?.strengthMin != null && value < loraInfo.strengthMin) return true;
return false;
};
// Left stepper only exists in dual mode: it is the MODEL strength, and the
// right stepper becomes CLIP (strengthTwo) — same column order as the canvas
// rows, where the rightmost number is clip.
const modelStrength = strengthControl(root, {
get: () => raw.strength ?? 1,
set: (v) => {
raw.strength = v;
},
onCommit: () => refreshPowerLoraDom(node),
});
const clipStrength = strengthControl(root, {
get: () => (isDualActive() ? (raw.strengthTwo ?? 1) : (raw.strength ?? 1)),
set: (v) => {
if (isDualActive()) raw.strengthTwo = v;
else raw.strength = v;
},
onCommit: () => refreshPowerLoraDom(node),
});
function render() {
const dualActive = isDualActive();
if (dualActive && raw.strengthTwo == null) {
raw.strengthTwo = raw.strength ?? 1;
} else if (!dualActive && dual && raw.strengthTwo != null) {
raw.strengthTwo = null;
}
root.classList.toggle("mxd-lora-off", raw.on !== true);
toggle.setAttribute("aria-checked", raw.on === true ? "true" : "false");
const label = String(raw.lora || "None");
nameText.textContent = label;
nameBtn.title = label;
const hasLora = !!raw.lora && raw.lora !== "None";
infoBtn.style.display = hasLora ? "" : "none";
// In dual mode both steppers show; otherwise only the single (clip slot).
modelStrength.input.parentElement.style.display = dualActive ? "" : "none";
modelStrength.render();
clipStrength.render();
modelStrength.input.classList.toggle("mxd-lora-out-of-range", outOfRange(raw.strength ?? 1));
clipStrength.input.classList.toggle(
"mxd-lora-out-of-range",
outOfRange(dualActive ? (raw.strengthTwo ?? 1) : (raw.strength ?? 1)),
);
}
function loadLoraInfo(force = false) {
if (!loraInfoPromise || force) {
const hasLora = raw.lora && raw.lora !== "None";
loraInfoPromise = (hasLora
? LORA_INFO_SERVICE.getInfo(raw.lora, force, true)
: Promise.resolve(null)
).then((info) => {
loraInfo = info;
render();
});
}
return loraInfoPromise;
}
toggle.addEventListener("click", (e) => {
e.stopPropagation();
raw.on = raw.on !== true;
render();
refreshPowerLoraDom(node);
});
nameBtn.addEventListener("click", (e) => {
e.stopPropagation();
showLoraChooser(e, (value) => {
if (typeof value === "string") {
raw.lora = value;
loraInfo = null;
loadLoraInfo(true);
render();
}
});
});
infoBtn.addEventListener("click", (e) => {
e.stopPropagation();
showInfoDialog();
});
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
node.removeWidget(widget);
refreshPowerLoraDom(node);
node.setDirtyCanvas(true, true);
});
function showInfoDialog() {
if (!raw.lora || raw.lora === "None") return;
const dialog = new MxdLoraInfoDialog(raw.lora).show();
dialog.addEventListener("close", (e) => {
if (e.detail?.dirty) loadLoraInfo(true);
});
}
widget = addRowWidget(node, {
name,
element: root,
height: ROW_HEIGHT,
getState: () => state,
// Mirrors PowerLoraLoaderWidget.serializeValue exactly: `strengthTwo` is
// OMITTED (not null) when strengths aren't split, because the Python side
// tests `"strengthTwo" in lora` when building its lora list output.
serializeState: () => {
const out = { ...raw };
if (!dual || !isDualActive()) {
delete out.strengthTwo;
} else {
raw.strengthTwo = raw.strengthTwo ?? 1;
out.strengthTwo = raw.strengthTwo;
}
return out;
},
setState: (v) => {
if (v && typeof v === "object") {
Object.assign(raw, v);
}
loraInfo = null;
loraInfoPromise = null;
loadLoraInfo();
render();
},
});
// Keep the API the canvas rows expose, so the node class and its slot menus
// work against either implementation.
widget.setLora = (lora) => {
raw.lora = lora;
loraInfo = null;
loadLoraInfo(true);
render();
};
widget.showLoraInfoDialog = showInfoDialog;
widget.__mxdRefresher = registerRefresher(node, render);
render();
loadLoraInfo();
return widget;
}
// --- header row ----------------------------------------------------------
// "[toggle all] Toggle All Model Strength/Clip"
export function addDomHeaderRow(node, { dual = false } = {}) {
const root = el("div", "mxd-lora-row mxd-lora-header");
const toggle = button("mxd-lora-toggle", root);
toggle.setAttribute("role", "switch");
const label = el("span", "mxd-lora-header-label", root);
label.textContent = "Toggle All";
// Invisible stand-ins for each row's info/remove buttons, so the captions
// below line up with the strength steppers under the same flex math.
el("span", "mxd-lora-icon mxd-lora-spacer", root);
el("span", "mxd-lora-icon mxd-lora-spacer", root);
const modelCol = el("span", "mxd-lora-header-col", root);
const strengthCol = el("span", "mxd-lora-header-col", root);
function render() {
const anyRows = node.hasLoraWidgets?.() === true;
root.style.display = anyRows ? "" : "none";
if (!anyRows) return;
const dualActive = dual && node.isShowingSeparateStrengths?.() === true;
const all = node.allLorasState?.();
toggle.setAttribute("aria-checked", all === true ? "true" : all === null ? "mixed" : "false");
modelCol.style.display = dualActive ? "" : "none";
modelCol.textContent = "Model";
strengthCol.textContent = dualActive ? "Clip" : "Strength";
}
toggle.addEventListener("click", (e) => {
e.stopPropagation();
node.toggleAllLoras?.();
refreshPowerLoraDom(node);
});
// Same `value` the canvas PowerLoraLoaderHeaderWidget carries, so it occupies
// the same slot in widgets_values.
const widget = addRowWidget(node, {
name: "PowerLoraLoaderHeaderWidget",
element: root,
height: HEADER_HEIGHT,
getState: () => ({ type: "PowerLoraLoaderHeaderWidget" }),
setState: () => {},
});
widget.__mxdRefresher = registerRefresher(node, render);
render();
return widget;
}
// --- "+ Add Lora" --------------------------------------------------------
export function addDomAddLoraButton(node, onPick) {
const root = el("button", "mxd-lora-add");
root.type = "button";
root.textContent = "+ Add Lora";
root.addEventListener("pointerdown", (e) => e.stopPropagation());
root.addEventListener("click", (e) => {
e.stopPropagation();
showLoraChooser(e, (value) => {
if (typeof value === "string" && value !== "NONE") {
onPick(value);
growNodeToFit(node);
refreshPowerLoraDom(node);
}
});
});
// Empty-string value matches the canvas MxdBetterButtonWidget it replaces.
return addRowWidget(node, {
name: "mxd_add_lora_button",
element: root,
height: BUTTON_HEIGHT,
getState: () => "",
setState: () => {},
});
}
+12 -4
View File
@@ -2,7 +2,7 @@
// strengths (the "Show Strengths" node property). Shared machinery lives in
// power_lora_base.js; this file only adds the dual-strength row behavior.
import { app } from "../../../scripts/app.js";
import { nodeDrawWidth } from "../lib/mxd_nodes2.js";
import { nodeDrawWidth, redrawWidget } from "../lib/mxd_nodes2.js";
import { drawNumberWidgetPart, isLowQuality } from "../lib/mxd_utils_canvas.js";
import {
MxdPowerLoraLoaderBase,
@@ -19,8 +19,6 @@ class MxdPowerLoraLoader extends MxdPowerLoraLoaderBase {
static title = NODE_TYPE;
static type = NODE_TYPE;
static comfyClass = NODE_TYPE;
// This node's "Show Strengths" property can split model/clip strengths.
static loraRowSupportsDual = true;
static [PROP_LABEL_SHOW_STRENGTHS_STATIC] = {
type: "combo",
@@ -192,6 +190,7 @@ class PowerLoraLoaderWidget extends PowerLoraBaseWidget {
let prop = isTwo ? "strengthTwo" : "strength";
this.haveMouseMovedStrength = true;
this.value[prop] = (this.value[prop] ?? 1) + event.deltaX * 0.05;
redrawWidget(this);
}
}
@@ -207,7 +206,15 @@ class PowerLoraLoaderWidget extends PowerLoraBaseWidget {
if (this.haveMouseMovedStrength) return;
let prop = isTwo ? "strengthTwo" : "strength";
const canvas = app.canvas;
canvas.prompt("Value", this.value[prop], (v) => (this.value[prop] = Number(v)), event);
canvas.prompt(
"Value",
this.value[prop],
(v) => {
this.value[prop] = Number(v);
redrawWidget(this);
},
event,
);
}
stepStrength(direction, isTwo = false) {
@@ -215,6 +222,7 @@ class PowerLoraLoaderWidget extends PowerLoraBaseWidget {
let prop = isTwo ? "strengthTwo" : "strength";
let strength = (this.value[prop] ?? 1) + step * direction;
this.value[prop] = Math.round(strength * 100) / 100;
redrawWidget(this);
}
}