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

372 lines
13 KiB
TypeScript

/**
* WorkflowNodesTab lists all workflow nodes and exposes each node's parameters
* inline through a collapsible section.
*
* Subgraph container nodes show an extra toggle to expand/collapse their inner
* nodes as a nested group below the container.
*
* The node list DOM is only rebuilt when the set of nodes actually changes —
* value sync runs every frame without touching the DOM structure.
*/
import { focusHostCanvasNode, getHostGraph, getSelectedHostCanvasNodeIds } from "../../../app/hostAdapter.js";
import { getGraphNodes, getNodeSubgraphs } from "../../../app/graphTraversal.js";
import { NodeWidgetRenderer } from "./NodeWidgetRenderer.js";
type LooseRecord = Record<string, any>;
type WorkflowTreeNode = { node: LooseRecord; children: WorkflowTreeNode[] };
type WorkflowTreeItemElement = HTMLDivElement & {
_mjrNodeId?: string;
_mjrParentTreeItem?: WorkflowTreeItemElement | null;
_mjrChildrenToggle?: HTMLButtonElement;
_mjrChildrenEl?: HTMLDivElement;
_mjrChildCount?: number;
};
export class WorkflowNodesTab {
[key: string]: any;
_searchQuery: string;
_expandedNodeIds: Set<string>;
_expandedChildrenIds: Set<string>;
_renderers: NodeWidgetRenderer[];
_el: HTMLElement;
_searchInput!: HTMLInputElement;
_list!: HTMLDivElement;
_lastNodeSig: string;
_lastSelectedId: string;
constructor() {
this._searchQuery = "";
/** Which node widget panels are open (at most one at a time). */
this._expandedNodeIds = new Set();
/** Which subgraph container nodes have their children list open. */
this._expandedChildrenIds = new Set();
/** Flat list of every NodeWidgetRenderer currently in the DOM. */
this._renderers = [];
this._el = this._build();
this._lastNodeSig = "";
this._lastSelectedId = "";
}
get el() {
return this._el;
}
refresh() {
this._maybeRebuildList();
for (const renderer of this._renderers) renderer.syncFromGraph();
}
forceRebuild() {
this._lastNodeSig = "";
this._maybeRebuildList();
}
dispose() {
for (const renderer of this._renderers) renderer.dispose();
this._renderers = [];
this._el?.remove?.();
}
_build() {
const wrap = document.createElement("div");
wrap.className = "mjr-ws-nodes-tab";
const searchWrap = document.createElement("div");
searchWrap.className = "mjr-ws-search-wrap";
const searchIcon = document.createElement("i");
searchIcon.className = "pi pi-search mjr-ws-search-icon";
searchIcon.setAttribute("aria-hidden", "true");
searchWrap.appendChild(searchIcon);
this._searchInput = document.createElement("input");
this._searchInput.type = "text";
this._searchInput.className = "mjr-ws-search";
this._searchInput.placeholder = "Search nodes...";
this._searchInput.addEventListener("input", () => {
this._searchQuery = this._searchInput.value;
this.forceRebuild();
});
searchWrap.appendChild(this._searchInput);
const clearBtn = document.createElement("button");
clearBtn.type = "button";
clearBtn.className = "mjr-ws-search-clear";
clearBtn.title = "Clear search";
clearBtn.innerHTML = '<i class="pi pi-times" aria-hidden="true"></i>';
clearBtn.addEventListener("click", () => {
this._searchInput.value = "";
this._searchQuery = "";
this.forceRebuild();
});
searchWrap.appendChild(clearBtn);
wrap.appendChild(searchWrap);
this._list = document.createElement("div");
this._list.className = "mjr-ws-nodes-list";
wrap.appendChild(this._list);
return wrap;
}
_syncCanvasSelection() {
const selectedIds = _getSelectedNodeIds();
const selectedId = selectedIds[0] || "";
// Toggle the highlight class on every render so it survives DOM
// rebuilds (signature changes after adding/removing a node).
let target: any = null;
for (const renderer of this._renderers) {
const isSelected = String(renderer._node?.id ?? "") === selectedId;
renderer.el?.classList?.toggle("is-selected-from-graph", isSelected);
if (isSelected) target = renderer;
}
if (!selectedId) {
this._lastSelectedId = "";
return;
}
if (selectedId === this._lastSelectedId) return;
this._lastSelectedId = selectedId;
if (!target) return;
if (!target._expanded) target.setExpanded(true);
// Promote the selected node to the first position of its container.
// For nested nodes, that's the top of their parent subgraph group;
// top-level nodes go to the very top of the sidebar list. Order
// resets naturally on the next full rebuild (graph topology change).
try {
const itemEl = target._mjrTreeItemEl || target.el;
this._openTreeBranch(itemEl);
const parent = itemEl?.parentElement;
if (parent && parent.firstElementChild !== itemEl) {
parent.insertBefore(itemEl, parent.firstElementChild);
}
target.el?.scrollIntoView({ block: "start", inline: "nearest" });
} catch (e: any) {
console.debug?.("[MFV] promote selected node failed", e);
}
}
_maybeRebuildList() {
const tree = _buildNodeTree(_getGraph());
const q = (this._searchQuery || "").toLowerCase().trim();
const filtered = q ? _filterTree(tree, q) : tree;
const sig = _treeSignature(filtered);
if (sig === this._lastNodeSig) {
this._syncCanvasSelection();
return;
}
this._lastNodeSig = sig;
for (const renderer of this._renderers) renderer.dispose();
this._renderers = [];
this._list.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("div");
empty.className = "mjr-ws-sidebar-empty";
empty.textContent = tree.length ? "No nodes match your search" : "No nodes in workflow";
this._list.appendChild(empty);
return;
}
this._renderItems(filtered, this._list, 0, null);
this._syncCanvasSelection();
}
/**
* Recursively render tree items into a container.
* @param {{node: object, children: object[]}[]} items
* @param {HTMLElement} container
* @param {number} depth
*/
_renderItems(
items: WorkflowTreeNode[],
container: HTMLElement,
depth: number,
parentItem: WorkflowTreeItemElement | null,
) {
for (const { node, children } of items) {
const nodeId = String(node?.id ?? "");
const childCount = children.length;
const itemEl: WorkflowTreeItemElement = document.createElement("div");
itemEl.className = "mjr-ws-tree-item";
itemEl.dataset.nodeId = nodeId;
itemEl._mjrNodeId = nodeId;
itemEl._mjrParentTreeItem = parentItem || null;
if (childCount > 0) itemEl.classList.add("mjr-ws-tree-item--subgraph");
if (depth > 0) itemEl.classList.add("mjr-ws-tree-item--nested");
const renderer = new NodeWidgetRenderer(node, {
collapsible: true,
expanded: this._expandedNodeIds.has(nodeId),
depth,
isSubgraph: childCount > 0,
childCount,
onLocate: () => _focusNode(node),
onToggle: (expanded) => {
if (expanded) {
this._expandedNodeIds = new Set([nodeId]);
for (const r of this._renderers) {
if (r !== renderer) r.setExpanded(false);
}
} else {
this._expandedNodeIds.delete(nodeId);
}
},
});
renderer._mjrTreeItemEl = itemEl;
this._renderers.push(renderer);
itemEl.appendChild(renderer.el);
if (childCount > 0) {
const isOpen = this._expandedChildrenIds.has(nodeId);
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "mjr-ws-children-toggle";
if (depth > 0) toggleBtn.classList.add("mjr-ws-children-toggle--nested");
_setChildrenToggleLabel(toggleBtn, childCount, isOpen);
const childrenEl = document.createElement("div");
childrenEl.className = "mjr-ws-children";
childrenEl.hidden = !isOpen;
itemEl._mjrChildrenToggle = toggleBtn;
itemEl._mjrChildrenEl = childrenEl;
itemEl._mjrChildCount = childCount;
this._renderItems(children, childrenEl, depth + 1, itemEl);
toggleBtn.addEventListener("click", () => {
this._setTreeItemChildrenOpen(itemEl, childrenEl.hidden);
});
itemEl.appendChild(toggleBtn);
itemEl.appendChild(childrenEl);
}
container.appendChild(itemEl);
}
}
_setTreeItemChildrenOpen(itemEl: any, open: any) {
if (!itemEl?._mjrChildrenEl || !itemEl?._mjrChildrenToggle) return;
const nodeId = String(itemEl._mjrNodeId || "");
itemEl._mjrChildrenEl.hidden = !open;
if (nodeId) {
if (open) this._expandedChildrenIds.add(nodeId);
else this._expandedChildrenIds.delete(nodeId);
}
_setChildrenToggleLabel(
itemEl._mjrChildrenToggle,
Number(itemEl._mjrChildCount) || 0,
open,
);
}
_openTreeBranch(itemEl: any) {
let current = itemEl || null;
while (current) {
const parent = current._mjrParentTreeItem || null;
if (parent) this._setTreeItemChildrenOpen(parent, true);
current = parent;
}
this._setTreeItemChildrenOpen(itemEl, true);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function _getGraph() {
try {
return getHostGraph();
} catch {
return null;
}
}
function _getSubgraphsFromNode(node: any) {
return getNodeSubgraphs(node);
}
/**
* Build a recursive tree: [{node, children}] from a LiteGraph graph object.
*/
function _buildNodeTree(graph: any, visited = new Set()): any[] {
if (!graph || visited.has(graph)) return [];
visited.add(graph);
const nodes = getGraphNodes(graph);
const result = [];
for (const node of nodes) {
if (!node) continue;
const subgraphs = _getSubgraphsFromNode(node);
const children = subgraphs.flatMap((sg: any) => _buildNodeTree(sg, visited));
result.push({ node, children });
}
return result;
}
/**
* Recursively filter tree items. A container is kept if it matches OR any child matches.
*/
function _filterTree(items: any, q: any): any[] {
const result = [];
for (const { node, children } of items) {
const nodeMatches =
(node.type || "").toLowerCase().includes(q) ||
(node.title || "").toLowerCase().includes(q);
const filteredChildren = _filterTree(children, q);
if (nodeMatches || filteredChildren.length > 0) {
result.push({ node, children: filteredChildren });
}
}
return result;
}
/** Depth-first signature of all node IDs — encodes both set and tree order. */
function _treeSignature(items: any) {
const ids: any[] = [];
function collect(arr: any) {
for (const { node, children } of arr) {
ids.push(node.id);
ids.push("[");
collect(children);
ids.push("]");
}
}
collect(items);
return ids.join(",");
}
function _setChildrenToggleLabel(btn: any, count: any, isOpen: any) {
const icon = isOpen ? "pi-chevron-down" : "pi-chevron-right";
btn.textContent = "";
const iconEl = document.createElement("i");
iconEl.className = `pi ${icon}`;
iconEl.setAttribute("aria-hidden", "true");
btn.appendChild(iconEl);
const label = document.createElement("span");
label.textContent = `${count} inner node${count !== 1 ? "s" : ""}`;
btn.appendChild(label);
btn.setAttribute("aria-expanded", String(isOpen));
}
function _focusNode(node: any) {
try {
focusHostCanvasNode(node);
} catch (e: any) {
console.debug?.("[MFV] _focusNode", e);
}
}
function _getSelectedNodeIds() {
try {
return getSelectedHostCanvasNodeIds();
} catch (e: any) {
console.debug?.("[MFV] _getSelectedNodeIds", e);
}
return [];
}