372 lines
13 KiB
TypeScript
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 [];
|
|
}
|