269 lines
9.7 KiB
TypeScript
269 lines
9.7 KiB
TypeScript
/**
|
|
* widgetAdapters — pure functions to convert a ComfyUI LGraphNode widget
|
|
* into an HTML input element and to write values back.
|
|
*
|
|
* No module state — every function is stateless.
|
|
*/
|
|
|
|
import { getHostCanvas, notifyHostNodeGraphChanged } from "../../../app/hostAdapter.js";
|
|
|
|
type LooseRecord = Record<string, any>;
|
|
type WidgetRecord = LooseRecord;
|
|
type WidgetChangeHandler = (newValue: unknown) => void;
|
|
type AutoFitElement<T extends HTMLElement> = T & { _mjrAutoFit?: () => void };
|
|
|
|
/**
|
|
* Create an HTML input element matching a ComfyUI widget type.
|
|
* @param {object} widget LGraphNode widget ({ name, type, value, options, callback })
|
|
* @param {(newValue: any) => void} onChange Called when the user edits the input
|
|
* @param {object|null} [node] Parent LGraphNode — passed to widget.callback for proper ComfyUI compat
|
|
* @returns {HTMLElement}
|
|
*/
|
|
export function createWidgetInput(
|
|
widget: WidgetRecord,
|
|
onChange?: WidgetChangeHandler,
|
|
node: LooseRecord | null = null,
|
|
): HTMLElement {
|
|
const type = String(widget?.type || "").toLowerCase();
|
|
switch (type) {
|
|
case "number":
|
|
case "int":
|
|
case "float":
|
|
return _numberInput(widget, onChange, node);
|
|
case "combo":
|
|
return _comboInput(widget, onChange, node);
|
|
case "text":
|
|
case "string":
|
|
case "customtext":
|
|
return _textInput(widget, onChange, node);
|
|
case "toggle":
|
|
case "boolean":
|
|
return _toggleInput(widget, onChange, node);
|
|
default:
|
|
return _readonlyInput(widget);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a value into a ComfyUI widget and trigger its callback + canvas repaint.
|
|
* @param {object} widget
|
|
* @param {unknown} newValue
|
|
* @param {object|null} [node] Parent LGraphNode
|
|
* @returns {boolean}
|
|
*/
|
|
export function writeWidgetValue(
|
|
widget: WidgetRecord | null | undefined,
|
|
newValue: any,
|
|
node: LooseRecord | null = null,
|
|
): boolean {
|
|
if (!widget) return false;
|
|
const type = String(widget.type || "").toLowerCase();
|
|
|
|
if (type === "number" || type === "int" || type === "float") {
|
|
const n = Number(newValue);
|
|
if (Number.isNaN(n)) return false;
|
|
const opts = widget.options ?? {};
|
|
const min = opts.min ?? -Infinity;
|
|
const max = opts.max ?? Infinity;
|
|
let clamped = Math.min(max, Math.max(min, n));
|
|
// Round integers
|
|
if (type === "int" || opts.precision === 0 || opts.round === 1) {
|
|
clamped = Math.round(clamped);
|
|
}
|
|
widget.value = clamped;
|
|
} else if (type === "toggle" || type === "boolean") {
|
|
widget.value = Boolean(newValue);
|
|
} else {
|
|
widget.value = newValue;
|
|
}
|
|
|
|
// Sync ComfyUI's own DOM element for this widget.
|
|
// customtext/string widgets use widget.inputEl (a textarea positioned on the canvas).
|
|
// Without this, the canvas widget DOM shows the old value even though widget.value changed.
|
|
// Called AFTER the callback so it reflects the final snapped/clamped value.
|
|
|
|
try {
|
|
const canvas = getHostCanvas();
|
|
const resolvedNode = node ?? widget?.parent ?? null;
|
|
// Save value before callback: LiteGraph number callbacks snap widget.value to
|
|
// multiples of step2 (e.g. 1025 → 1024 for step2=16). We restore afterwards so
|
|
// the sidebar always writes the exact value the user typed/clicked.
|
|
const savedValue = widget.value;
|
|
// Call with full LiteGraph/ComfyUI signature: (value, canvas, node, event, widget)
|
|
widget.callback?.(widget.value, canvas, resolvedNode, null, widget);
|
|
// Restore exact value (bypasses step2 snapping while keeping callback side-effects).
|
|
if (type === "number" || type === "int" || type === "float") {
|
|
widget.value = savedValue;
|
|
}
|
|
_syncWidgetDomElement(widget);
|
|
notifyHostNodeGraphChanged(resolvedNode);
|
|
} catch (e: any) {
|
|
console.debug?.("[MFV] writeWidgetValue", e);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Private builders ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Push widget.value into ComfyUI's own DOM element for this widget.
|
|
* ComfyUI creates a real textarea/input DOM node for customtext/string widgets
|
|
* (widget.inputEl) and positions it on top of the canvas. Updating widget.value
|
|
* alone does not refresh that element — we must set its value directly.
|
|
*/
|
|
function _syncWidgetDomElement(widget: WidgetRecord) {
|
|
const strVal = String(widget.value ?? "");
|
|
// ComfyUI customtext: widget.inputEl is the canvas-overlay textarea.
|
|
// For PromotedWidgetView (subgraph exposed widgets), there is no direct inputEl:
|
|
// the real DOM textarea lives on cachedDeepestByFrame.widget.inputEl.
|
|
const el =
|
|
widget?.inputEl ??
|
|
widget?.element ??
|
|
widget?.el ??
|
|
widget?.cachedDeepestByFrame?.widget?.inputEl ??
|
|
widget?.cachedDeepestByFrame?.widget?.element ??
|
|
widget?.cachedDeepestByFrame?.widget?.el ??
|
|
null;
|
|
const valueEl = el as { value?: unknown } | null;
|
|
if (valueEl != null && "value" in valueEl && valueEl.value !== strVal) {
|
|
valueEl.value = strVal;
|
|
}
|
|
}
|
|
|
|
function _numberInput(
|
|
widget: WidgetRecord,
|
|
onChange?: WidgetChangeHandler,
|
|
node: LooseRecord | null = null,
|
|
): HTMLInputElement {
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.className = "mjr-ws-input";
|
|
input.value = widget.value ?? "";
|
|
const opts = widget.options ?? {};
|
|
const wtype = String(widget?.type || "").toLowerCase();
|
|
const isInt = wtype === "int" || opts.precision === 0 || opts.round === 1;
|
|
|
|
if (opts.min != null) input.min = String(opts.min);
|
|
if (opts.max != null) input.max = String(opts.max);
|
|
|
|
// Arrow step: always 1 for integers so the spinner increments by 1.
|
|
// options.step2 (the LiteGraph snap multiplier) is intentionally ignored here
|
|
// because writeWidgetValue restores widget.value after the callback, bypassing snap.
|
|
if (isInt) {
|
|
input.step = "1";
|
|
} else {
|
|
const precision = opts.precision;
|
|
input.step = precision != null ? String(Math.pow(10, -precision)) : "any";
|
|
}
|
|
|
|
// Real-time: write on every keystroke / arrow click.
|
|
input.addEventListener("input", () => {
|
|
const raw = input.value;
|
|
if (raw === "" || raw === "-" || raw === "." || raw.endsWith(".")) return;
|
|
writeWidgetValue(widget, raw, node);
|
|
onChange?.(widget.value);
|
|
});
|
|
// On blur / Enter: snap display to the clamped/rounded widget value.
|
|
input.addEventListener("change", () => {
|
|
if (writeWidgetValue(widget, input.value, node)) {
|
|
input.value = String(widget.value);
|
|
onChange?.(widget.value);
|
|
}
|
|
});
|
|
return input;
|
|
}
|
|
|
|
function _comboInput(
|
|
widget: WidgetRecord,
|
|
onChange?: WidgetChangeHandler,
|
|
node: LooseRecord | null = null,
|
|
): HTMLSelectElement {
|
|
const select = document.createElement("select");
|
|
select.className = "mjr-ws-input";
|
|
let values = widget.options?.values ?? [];
|
|
if (typeof values === "function") {
|
|
try {
|
|
values = values() ?? [];
|
|
} catch {
|
|
values = [];
|
|
}
|
|
}
|
|
if (!Array.isArray(values)) values = [];
|
|
for (const v of values) {
|
|
const opt = document.createElement("option");
|
|
const label = typeof v === "string" ? v : (v?.content ?? v?.value ?? v?.text ?? String(v));
|
|
opt.value = label;
|
|
opt.textContent = label;
|
|
if (label === String(widget.value)) opt.selected = true;
|
|
select.appendChild(opt);
|
|
}
|
|
select.addEventListener("change", () => {
|
|
if (writeWidgetValue(widget, select.value, node)) onChange?.(widget.value);
|
|
});
|
|
return select;
|
|
}
|
|
|
|
function _textInput(
|
|
widget: WidgetRecord,
|
|
onChange?: WidgetChangeHandler,
|
|
node: LooseRecord | null = null,
|
|
): HTMLDivElement {
|
|
const wrapper: AutoFitElement<HTMLDivElement> = document.createElement("div");
|
|
wrapper.className = "mjr-ws-text-wrapper";
|
|
|
|
const textarea: AutoFitElement<HTMLTextAreaElement> = document.createElement("textarea");
|
|
textarea.className = "mjr-ws-input mjr-ws-textarea";
|
|
textarea.value = widget.value ?? "";
|
|
textarea.rows = 2;
|
|
|
|
const autoFit = () => {
|
|
textarea.style.height = "auto";
|
|
textarea.style.height = textarea.scrollHeight + "px";
|
|
};
|
|
|
|
textarea.addEventListener("change", () => {
|
|
if (writeWidgetValue(widget, textarea.value, node)) onChange?.(widget.value);
|
|
});
|
|
textarea.addEventListener("input", () => {
|
|
writeWidgetValue(widget, textarea.value, node);
|
|
onChange?.(widget.value);
|
|
autoFit();
|
|
});
|
|
|
|
wrapper.appendChild(textarea);
|
|
wrapper._mjrAutoFit = autoFit;
|
|
textarea._mjrAutoFit = autoFit;
|
|
|
|
requestAnimationFrame(autoFit);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function _toggleInput(
|
|
widget: WidgetRecord,
|
|
onChange?: WidgetChangeHandler,
|
|
node: LooseRecord | null = null,
|
|
): HTMLLabelElement {
|
|
const label = document.createElement("label");
|
|
label.className = "mjr-ws-toggle-label";
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.className = "mjr-ws-checkbox";
|
|
input.checked = Boolean(widget.value);
|
|
input.addEventListener("change", () => {
|
|
if (writeWidgetValue(widget, input.checked, node)) onChange?.(widget.value);
|
|
});
|
|
label.appendChild(input);
|
|
return label;
|
|
}
|
|
|
|
function _readonlyInput(widget: WidgetRecord): HTMLInputElement {
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.className = "mjr-ws-input mjr-ws-readonly";
|
|
input.value = widget.value != null ? String(widget.value) : "";
|
|
input.readOnly = true;
|
|
input.tabIndex = -1;
|
|
return input;
|
|
}
|