diff --git a/web/WEB.md b/web/WEB.md index 3d75df3..943c112 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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 diff --git a/web/index.js b/web/index.js index 78cecb7..dce0d23 100644 --- a/web/index.js +++ b/web/index.js @@ -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'; diff --git a/web/lib/mxd_nodes2.js b/web/lib/mxd_nodes2.js index 80c6dfb..34d5f29 100644 --- a/web/lib/mxd_nodes2.js +++ b/web/lib/mxd_nodes2.js @@ -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 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); +} diff --git a/web/lib/mxd_power_lora.css b/web/lib/mxd_power_lora.css deleted file mode 100644 index b5ba28b..0000000 --- a/web/lib/mxd_power_lora.css +++ /dev/null @@ -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); -} diff --git a/web/lib/mxd_utils_widgets.js b/web/lib/mxd_utils_widgets.js index ae110a8..2774225 100644 --- a/web/lib/mxd_utils_widgets.js +++ b/web/lib/mxd_utils_widgets.js @@ -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; } diff --git a/web/nodes/power_lora_base.js b/web/nodes/power_lora_base.js index 24d2a17..a9fbb70 100644 --- a/web/nodes/power_lora_base.js +++ b/web/nodes/power_lora_base.js @@ -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; } diff --git a/web/nodes/power_lora_dom.js b/web/nodes/power_lora_dom.js deleted file mode 100644 index 9cd0895..0000000 --- a/web/nodes/power_lora_dom.js +++ /dev/null @@ -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: () => {}, - }); -} diff --git a/web/nodes/power_lora_loader.js b/web/nodes/power_lora_loader.js index 00ef388..4f22316 100644 --- a/web/nodes/power_lora_loader.js +++ b/web/nodes/power_lora_loader.js @@ -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); } }