Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/workflowSidebar/NodeWidgetRenderer.ts
T

431 lines
16 KiB
TypeScript

/**
* NodeWidgetRenderer renders a single ComfyUI LGraphNode's widgets
* as an editable HTML section.
*/
import { focusHostCanvasNode, notifyHostNodeGraphChanged } from "../../../app/hostAdapter.js";
import { createWidgetInput } from "./widgetAdapters.js";
type LooseRecord = Record<string, any>;
type AutoFitElement<T extends HTMLElement> = T & { _mjrAutoFit?: () => void };
interface NodeWidgetRendererOptions {
onLocate?: () => void;
collapsible?: boolean;
expanded?: boolean;
onToggle?: (expanded: boolean) => void;
depth?: number;
isSubgraph?: boolean;
childCount?: number;
}
const SKIP_TYPES = new Set(["imageupload", "button", "hidden"]);
// Note nodes store their text in node.properties instead of widgets.
const NOTE_TYPE_RE = /\bnote\b|markdown/i;
function _isNoteNode(node: LooseRecord | null | undefined): boolean {
return NOTE_TYPE_RE.test(String(node?.type || ""));
}
function _getNoteText(node: LooseRecord | null | undefined): string {
const props = node?.properties ?? {};
if (typeof props.text === "string") return props.text;
if (typeof props.value === "string") return props.value;
if (typeof props.markdown === "string") return props.markdown;
const w0 = node?.widgets?.[0];
if (w0 != null && w0.value != null) return String(w0.value);
return "";
}
function _setNoteText(node: LooseRecord | null | undefined, value: string) {
const props = node?.properties;
if (props) {
if ("text" in props) props.text = value;
else if ("value" in props) props.value = value;
else if ("markdown" in props) props.markdown = value;
else props.text = value;
}
const w0 = node?.widgets?.[0];
if (w0) w0.value = value;
}
// UUID or long hex hash — not a human-readable type name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_HASH_RE = /^[0-9a-f]{20,}$/i;
function _firstText(...values: unknown[]): string {
for (const value of values) {
const text = String(value || "").trim();
if (text) return text;
}
return "";
}
function _isOpaqueType(type: string): boolean {
return UUID_RE.test(type) || HEX_HASH_RE.test(type);
}
function _getNodeTitle(node: LooseRecord | null | undefined): string {
return _firstText(
node?.title,
node?.properties?.title,
node?.properties?.name,
node?.properties?.label,
node?.name,
);
}
function _getNodeDisplayType(
node: LooseRecord | null | undefined,
{ isSubgraph = false }: { isSubgraph?: boolean } = {},
): string {
const type = String(node?.type || "").trim();
const title = _getNodeTitle(node);
if ((isSubgraph || _isOpaqueType(type)) && title) return title;
if (_isOpaqueType(type)) return "Subgraph";
return type || title || `Node #${node?.id}`;
}
function _getNodeSecondaryTitle(
node: LooseRecord | null | undefined,
displayType: string,
{ isSubgraph = false }: { isSubgraph?: boolean } = {},
): string {
const type = String(node?.type || "").trim();
const title = _getNodeTitle(node);
if (isSubgraph && type && !_isOpaqueType(type) && type !== displayType) return type;
if (title && title !== type && title !== displayType) return title;
return "";
}
export class NodeWidgetRenderer {
[key: string]: any;
_node: LooseRecord;
_onLocate: (() => void) | null;
_onToggle: ((expanded: boolean) => void) | null;
_collapsible: boolean;
_expanded: boolean;
_depth: number;
_isSubgraph: boolean;
_childCount: number;
_el: HTMLElement | null;
_header: HTMLElement | null;
_body: HTMLElement | null;
_toggleBtn: HTMLButtonElement | null;
_inputMap: Map<string, HTMLElement>;
_autoFits: Array<() => void>;
_noteTextarea: AutoFitElement<HTMLTextAreaElement> | null;
_subgraphHeaderTitle: string;
_mjrTreeItemEl?: HTMLElement | null;
/**
* @param {object} node
* @param {object} [opts]
* @param {() => void} [opts.onLocate]
* @param {boolean} [opts.collapsible]
* @param {boolean} [opts.expanded]
* @param {(expanded: boolean) => void} [opts.onToggle]
* @param {number} [opts.depth] — 0 = top-level, >0 = inside a subgraph
*/
constructor(node: LooseRecord, opts: NodeWidgetRendererOptions = {}) {
this._node = node;
this._onLocate = opts.onLocate ?? null;
this._onToggle = typeof opts.onToggle === "function" ? opts.onToggle : null;
this._collapsible = Boolean(opts.collapsible);
this._expanded = opts.expanded !== false;
this._depth = opts.depth ?? 0;
this._isSubgraph = Boolean(opts.isSubgraph);
this._childCount = Math.max(0, Number(opts.childCount) || 0);
this._el = null;
this._header = null;
this._body = null;
this._toggleBtn = null;
this._inputMap = new Map();
this._autoFits = [];
this._noteTextarea = null;
this._subgraphHeaderTitle = "";
}
get el() {
if (!this._el) this._el = this._render();
return this._el;
}
syncFromGraph() {
// Note nodes: sync from node.properties.text instead of widgets
if (this._noteTextarea) {
const doc = this._el?.ownerDocument || document;
if (doc?.activeElement !== this._noteTextarea) {
const text = _getNoteText(this._node);
if (this._noteTextarea.value !== text) {
this._noteTextarea.value = text;
this._noteTextarea._mjrAutoFit?.();
}
}
return;
}
if (!this._node?.widgets) return;
const doc = this._el?.ownerDocument || document;
const activeEl = doc?.activeElement || null;
for (const w of this._node.widgets) {
const storedInput = this._inputMap.get(w.name);
const input = _resolveSyncInput(storedInput);
if (!input) continue;
if (input.type === "checkbox") {
const nextChecked = Boolean(w.value);
if (input.checked !== nextChecked) input.checked = nextChecked;
continue;
}
const nextValue = w.value != null ? String(w.value) : "";
if (String(input.value ?? "") === nextValue) continue;
if (activeEl && input === activeEl) continue;
input.value = nextValue;
(storedInput as AutoFitElement<HTMLElement> | null)?._mjrAutoFit?.();
(input as AutoFitElement<HTMLElement> | null)?._mjrAutoFit?.();
}
}
dispose() {
this._el?.remove();
this._el = null;
this._autoFits = [];
this._inputMap.clear();
}
setExpanded(expanded: any) {
this._expanded = Boolean(expanded);
this._applyExpandedState();
// Trigger textarea autofit now that the body is visible
if (this._expanded && this._autoFits?.length) {
requestAnimationFrame(() => {
for (const fn of this._autoFits) fn();
});
}
this._onToggle?.(this._expanded);
}
_render() {
const node = this._node;
const section = document.createElement("section");
section.className = "mjr-ws-node";
section.dataset.nodeId = String(node.id ?? "");
if (this._isSubgraph) {
section.classList.add("mjr-ws-node--subgraph");
section.dataset.subgraph = "true";
section.dataset.childCount = String(this._childCount);
}
if (this._depth > 0) {
section.dataset.depth = String(this._depth);
section.classList.add("mjr-ws-node--nested");
}
const header = document.createElement("div");
header.className = "mjr-ws-node-header";
if (this._collapsible) {
this._header = header;
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "mjr-icon-btn mjr-ws-node-toggle";
toggleBtn.title = this._expanded ? "Collapse node" : "Expand node";
toggleBtn.addEventListener("click", (event: any) => {
event.stopPropagation();
this.setExpanded(!this._expanded);
});
header.appendChild(toggleBtn);
this._toggleBtn = toggleBtn;
header.addEventListener("click", (event: any) => {
if ((event.target as Element | null)?.closest?.("button")) return;
this.setExpanded(!this._expanded);
});
header.title = this._expanded ? "Collapse node" : "Expand node";
}
const titleWrap = document.createElement("div");
titleWrap.className = "mjr-ws-node-title-wrap";
const typeText = document.createElement("span");
typeText.className = "mjr-ws-node-type";
const displayType = _getNodeDisplayType(node, { isSubgraph: this._isSubgraph });
typeText.textContent = displayType;
titleWrap.appendChild(typeText);
const customTitle = _getNodeSecondaryTitle(node, displayType, {
isSubgraph: this._isSubgraph,
});
if (customTitle) {
const title = document.createElement("span");
title.className = "mjr-ws-node-title";
title.textContent = customTitle;
titleWrap.appendChild(title);
}
header.appendChild(titleWrap);
if (this._isSubgraph) {
const badge = document.createElement("span");
badge.className = "mjr-ws-node-kind";
badge.title = `${this._childCount} inner node${this._childCount !== 1 ? "s" : ""}`;
const badgeIcon = document.createElement("i");
badgeIcon.className = "pi pi-sitemap";
badgeIcon.setAttribute("aria-hidden", "true");
badge.appendChild(badgeIcon);
const badgeText = document.createElement("span");
badgeText.textContent = "Subgraph";
badge.appendChild(badgeText);
const badgeCount = document.createElement("span");
badgeCount.className = "mjr-ws-node-kind-count";
badgeCount.textContent = String(this._childCount);
badge.appendChild(badgeCount);
header.appendChild(badge);
this._subgraphHeaderTitle = `${displayType} | Subgraph | ${this._childCount} inner node${this._childCount !== 1 ? "s" : ""}`;
header.title = this._subgraphHeaderTitle;
}
const locateBtn = document.createElement("button");
locateBtn.type = "button";
locateBtn.className = "mjr-icon-btn mjr-ws-locate";
locateBtn.title = "Locate on canvas";
locateBtn.innerHTML = '<i class="pi pi-map-marker" aria-hidden="true"></i>';
locateBtn.addEventListener("click", (event: any) => {
event.stopPropagation();
this._locateNode();
});
header.appendChild(locateBtn);
section.appendChild(header);
const body = document.createElement("div");
body.className = "mjr-ws-node-body";
// Note nodes store text in node.properties.text — widgets array is empty.
if (_isNoteNode(node)) {
const textarea = document.createElement("textarea");
textarea.className = "mjr-ws-input mjr-ws-textarea mjr-ws-note-textarea";
textarea.value = _getNoteText(node);
textarea.rows = 4;
const autoFit = () => {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
};
textarea.addEventListener("input", () => {
_setNoteText(node, textarea.value);
// Sync ComfyUI's own DOM element for this widget if it exists.
const w0 = node?.widgets?.[0];
const domEl = w0?.inputEl ?? w0?.element ?? w0?.el ?? null;
if (domEl != null && "value" in domEl && domEl.value !== textarea.value) {
domEl.value = textarea.value;
}
autoFit();
try {
notifyHostNodeGraphChanged(node);
} catch (_: any) {}
});
(textarea as AutoFitElement<HTMLTextAreaElement>)._mjrAutoFit = autoFit;
this._noteTextarea = textarea;
this._autoFits.push(autoFit);
body.appendChild(textarea);
this._body = body;
section.appendChild(body);
this._el = section;
this._applyExpandedState();
requestAnimationFrame(autoFit);
return section;
}
const widgets = node.widgets ?? [];
let hasVisibleWidget = false;
for (const w of widgets) {
const wType = String(w.type || "").toLowerCase();
if (SKIP_TYPES.has(wType)) continue;
if (w.hidden || w.options?.hidden) continue;
hasVisibleWidget = true;
const isText = wType === "text" || wType === "string" || wType === "customtext";
const row = document.createElement("div");
row.className = isText
? "mjr-ws-widget-row mjr-ws-widget-row--block"
: "mjr-ws-widget-row";
const label = document.createElement("label");
label.className = "mjr-ws-widget-label";
label.textContent = w.name || "";
const inputWrap = document.createElement("div");
inputWrap.className = "mjr-ws-widget-input";
const inputEl = createWidgetInput(w, () => {}, node);
inputWrap.appendChild(inputEl);
this._inputMap.set(w.name, inputEl);
// Collect autoFit functions from text wrappers so we can re-run them on expand
const autoFit =
(inputEl as AutoFitElement<HTMLElement>)._mjrAutoFit ??
(inputEl.querySelector?.("textarea") as AutoFitElement<HTMLTextAreaElement> | null)
?._mjrAutoFit;
if (autoFit) this._autoFits.push(autoFit);
row.appendChild(label);
row.appendChild(inputWrap);
body.appendChild(row);
}
if (!hasVisibleWidget) {
const empty = document.createElement("div");
empty.className = "mjr-ws-node-empty";
empty.textContent = "No editable parameters";
body.appendChild(empty);
}
this._body = body;
section.appendChild(body);
// Assign _el before calling _applyExpandedState so its guard passes.
this._el = section;
this._applyExpandedState();
return section;
}
_applyExpandedState() {
if (!this._el || !this._body) return;
this._el.classList.toggle("is-collapsible", this._collapsible);
this._el.classList.toggle("is-collapsed", this._collapsible && !this._expanded);
if (this._toggleBtn) {
const iconClass = this._expanded ? "pi pi-chevron-down" : "pi pi-chevron-right";
this._toggleBtn.textContent = "";
const icon = document.createElement("i");
icon.className = iconClass;
icon.setAttribute("aria-hidden", "true");
this._toggleBtn.appendChild(icon);
this._toggleBtn.title = this._expanded ? "Collapse node" : "Expand node";
this._toggleBtn.setAttribute("aria-expanded", String(this._expanded));
}
if (this._header) {
const actionTitle = this._expanded ? "Collapse node" : "Expand node";
this._header.title = this._subgraphHeaderTitle
? `${this._subgraphHeaderTitle} | ${actionTitle}`
: actionTitle;
}
}
_locateNode() {
if (this._onLocate) {
this._onLocate();
return;
}
const node = this._node;
if (!node) return;
try {
focusHostCanvasNode(node, { select: false, focusCanvas: false });
} catch (e: any) {
console.debug?.("[MFV sidebar] locateNode error", e);
}
}
}
function _resolveSyncInput(input: any) {
if (!input) return null;
if (input.classList?.contains?.("mjr-ws-text-wrapper")) {
return input.querySelector?.("textarea") ?? input;
}
return input;
}