230 lines
8.5 KiB
JavaScript
230 lines
8.5 KiB
JavaScript
// Order chain — builds and renders the hierarchical chain labels on input slots.
|
|
// Handles mode icons, child mode labels, chain walking, label updates, and
|
|
// wildcard conflict detection.
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { NODE_TYPE } from "./config.js";
|
|
import { isPromptChain, getSourceNode, hasCustomTitle, getConnectedInputs, hasOptions, getLinkInfo } from "./slot-utils.js";
|
|
import { attachSlotClickHandlers } from "./slot-dropdown.js";
|
|
import { countLabeledOptions, getSelfLabelOptions } from "./label-utils.js";
|
|
|
|
// Get the first-order mode icon based on the parent node's mode.
|
|
// Only shows when parent has 2+ connected inputs and isn't in switch mode.
|
|
function getModeIcon(node, slotIndex) {
|
|
const mode = node.properties?.pcrMode || "switch";
|
|
if (mode === "switch") {
|
|
const switchIndex = node.properties?.pcrSwitchIndex ?? 1;
|
|
if (slotIndex !== undefined) {
|
|
return switchIndex === slotIndex ? "✅ " : "❌ ";
|
|
}
|
|
return switchIndex === 0 ? "❌ " : "✅ ";
|
|
}
|
|
const connectedCount = getConnectedInputs(node).length;
|
|
if (connectedCount < 2) return "";
|
|
if (mode === "combine") return "📚 ";
|
|
if (mode === "roll") return "🎲 ";
|
|
if (mode === "iterate") return "♻️ ";
|
|
return "";
|
|
}
|
|
|
|
// Get the child node's mode label for display.
|
|
function getChildModeLabel(childNode) {
|
|
const mode = childNode.properties?.pcrMode || "switch";
|
|
if (mode === "roll") {
|
|
const rollIdx = childNode.properties?.pcrRollSelected;
|
|
if (rollIdx >= 1) {
|
|
const inputs = getConnectedInputs(childNode);
|
|
if (rollIdx <= inputs.length) {
|
|
const source = getSourceNode(childNode, inputs[rollIdx - 1]);
|
|
if (source) {
|
|
const name = hasCustomTitle(source) ? source.title : "PromptChain";
|
|
return `🎲 ${name}`;
|
|
}
|
|
}
|
|
// fallback: inline ::Label:: options
|
|
const selfLabels = getSelfLabelOptions(childNode);
|
|
if (rollIdx <= selfLabels.length) {
|
|
return `🎲 ${selfLabels[rollIdx - 1].label}`;
|
|
}
|
|
}
|
|
return "🎲 Randomize";
|
|
}
|
|
if (mode === "combine") return "📚 Combine";
|
|
if (mode === "switch") {
|
|
const switchIndex = childNode.properties?.pcrSwitchIndex ?? 1;
|
|
if (switchIndex === 0) return "❌ None";
|
|
const childInputs = getConnectedInputs(childNode);
|
|
if (switchIndex >= 1 && switchIndex <= childInputs.length) {
|
|
const selectedSlot = childInputs[switchIndex - 1];
|
|
const grandchild = getSourceNode(childNode, selectedSlot);
|
|
if (grandchild) {
|
|
const name = hasCustomTitle(grandchild) ? grandchild.title : "PromptChain";
|
|
return `✅ ${name}`;
|
|
}
|
|
}
|
|
// check self labels
|
|
const promptWidget = childNode.widgets?.find(w => w.name === "prompt");
|
|
const text = promptWidget?.value || "";
|
|
const labelMatch = text.match(/^::([^:]+)::/m);
|
|
if (labelMatch) {
|
|
const lines = text.split("\n").filter(l => /^::([^:]+)::/.test(l.trim()));
|
|
if (switchIndex >= 1 && switchIndex <= lines.length) {
|
|
const selected = lines[switchIndex - 1].trim().match(/^::([^:]+)::/);
|
|
if (selected) return `✅ ${selected[1]}`;
|
|
}
|
|
}
|
|
return "🟢 Switch";
|
|
}
|
|
if (mode === "iterate") {
|
|
const total = countLabeledOptions(childNode) || (childNode.properties?.pcrIterateTotal ?? 0);
|
|
const cur = childNode.properties?.pcrIterateCurrent ?? 0;
|
|
const cycle = childNode.properties?.pcrIterateCycle ?? 1;
|
|
if (total > 0) {
|
|
const display = `(${cur + 1}/${total})`;
|
|
const cycleText = cycle > 1 ? ` x${cycle}` : "";
|
|
return `♻️ Iterate ${display}${cycleText}`;
|
|
}
|
|
return "♻️ Iterate";
|
|
}
|
|
return "📚 Combine";
|
|
}
|
|
|
|
// Build order chain: walks the hierarchy from a child node downward.
|
|
// Returns array of { node, modeLabel, hasOptions } for each level.
|
|
export function buildOrderChain(childNode, maxDepth = 10) {
|
|
const chain = [];
|
|
let current = childNode;
|
|
|
|
for (let depth = 0; depth < maxDepth && current; depth++) {
|
|
if (!hasOptions(current)) break;
|
|
|
|
const modeLabel = getChildModeLabel(current);
|
|
chain.push({ node: current, modeLabel, hasOptions: true });
|
|
|
|
// walk to selected child if in switch mode
|
|
const mode = current.properties?.pcrMode || "switch";
|
|
if (mode === "switch") {
|
|
const switchIndex = current.properties?.pcrSwitchIndex ?? 1;
|
|
const inputs = getConnectedInputs(current);
|
|
if (switchIndex >= 1 && switchIndex <= inputs.length) {
|
|
const nextNode = getSourceNode(current, inputs[switchIndex - 1]);
|
|
if (nextNode && isPromptChain(nextNode)) {
|
|
current = nextNode;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
// Update input slot labels with full order chain.
|
|
export function updateInputLabels(node) {
|
|
if (!node.inputs) return;
|
|
|
|
for (let i = 0; i < node.inputs.length; i++) {
|
|
const slot = node.inputs[i];
|
|
if (!slot.name?.includes("in_")) continue;
|
|
|
|
if (slot.link == null) {
|
|
slot.label = "in";
|
|
slot.localized_name = "in";
|
|
} else {
|
|
const source = getSourceNode(node, slot);
|
|
if (source) {
|
|
const slotName = slot.name.replace("inputs.", "");
|
|
const slotIndex = parseInt(slotName.split("_")[1]) + 1;
|
|
const modeIcon = getModeIcon(node, slotIndex);
|
|
const name = hasCustomTitle(source) ? source.title : "PromptChain";
|
|
const chain = buildOrderChain(source);
|
|
|
|
if (chain.length > 0) {
|
|
const chainStr = chain.map(c => c.modeLabel).join(" ");
|
|
slot.label = `${modeIcon}${name} ${chainStr}`;
|
|
} else {
|
|
slot.label = `${modeIcon}${name}`;
|
|
}
|
|
slot.localized_name = slot.label;
|
|
|
|
// store chain data for click handlers
|
|
slot._pcrOrderChain = chain;
|
|
slot._pcrSourceNode = source;
|
|
}
|
|
}
|
|
// New object reference so Vue detects the label change — but skip
|
|
// during active link drags to preserve slot identity for connectSlots.
|
|
if (!app.canvas?.linkConnector?.isConnecting) {
|
|
node.inputs[i] = { ...slot };
|
|
}
|
|
}
|
|
// notify Vue reactivity that slot labels changed
|
|
// node:slot-label:changed triggers shallowReactive array re-splice in Vue
|
|
node.graph?.trigger?.("node:slot-label:changed", {
|
|
nodeId: node.id, slotType: 1,
|
|
});
|
|
app.canvas?.setDirty(true, true);
|
|
// re-attach click handlers after Vue re-renders the label DOM
|
|
requestAnimationFrame(() => attachSlotClickHandlers(node));
|
|
}
|
|
|
|
// Update labels on all parent nodes connected to this node's outputs, recursively.
|
|
export function updateParentLabels(childNode) {
|
|
if (!childNode.outputs) return;
|
|
for (const output of childNode.outputs) {
|
|
if (!output.links) continue;
|
|
for (const linkId of output.links) {
|
|
const link = getLinkInfo(linkId);
|
|
if (!link) continue;
|
|
const parentNode = app.graph?.getNodeById?.(link.target_id);
|
|
if (parentNode?.comfyClass === NODE_TYPE) {
|
|
updateInputLabels(parentNode);
|
|
updateParentLabels(parentNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for inline wildcard + child input conflict.
|
|
// When both exist, inline ::Label:: lines are ignored (children take priority).
|
|
// Visual warning: adds a CSS class that the linter/style can use to highlight labels.
|
|
const toastShown = new Set();
|
|
export function checkWildcardConflict(node) {
|
|
// read from CM editor when available (survives widget value corruption/timing)
|
|
const text = node._pcrEditor?.state.doc.toString()
|
|
|| node.widgets?.find(w => w.name === "prompt")?.value
|
|
|| "";
|
|
const hasLabels = /^::([^:]+)::/m.test(text);
|
|
const hasChildren = getConnectedInputs(node).length > 0;
|
|
|
|
const conflict = hasLabels && hasChildren;
|
|
node._pcrWildcardConflict = conflict;
|
|
|
|
// dim via CSS class on the container — try live DOM first (after undo,
|
|
// node's container may be detached even with widget.onRemove cleanup)
|
|
const container = document.querySelector(`[data-node-id="${node.id}"] .pcr-editor`)
|
|
|| node._pcrEditor?.dom?.closest(".pcr-editor");
|
|
if (container) container.classList.toggle("pcr-wildcard-conflict", conflict);
|
|
|
|
// toast once per node per conflict
|
|
if (conflict && !toastShown.has(node.id)) {
|
|
toastShown.add(node.id);
|
|
if (app.extensionManager?.toast?.add) {
|
|
app.extensionManager.toast.add({
|
|
severity: "info",
|
|
summary: "PromptChain",
|
|
detail: "Inline wildcards dimmed — child inputs take priority",
|
|
life: 3000,
|
|
});
|
|
} else {
|
|
console.warn("[PromptChain] Inline wildcards ignored — child inputs take priority");
|
|
}
|
|
} else if (!conflict) {
|
|
toastShown.delete(node.id);
|
|
}
|
|
}
|
|
|
|
export function cleanupOrderChain(nodeId) {
|
|
toastShown.delete(nodeId);
|
|
}
|