Files

1008 lines
33 KiB
JavaScript

const { app } = window.comfyAPI.app;
const SWITCHBOARD_CLASS = "PenzillaWorkflowSwitchboard";
const RETURN_CLASS = "PenzillaWorkflowReturn";
const MODE_ALWAYS = LiteGraph.ALWAYS ?? 0;
const MODE_BYPASS = LiteGraph.BYPASS ?? 4;
const STORE_KEY = "wf_groups_json";
const ACTIVE_KEY = "active_group";
const AUTO_FOCUS_KEY = "auto_focus";
const FAVORITES_KEY = "wf_favorites_json";
const CATEGORY_FILTER_KEY = "wf_category_filter";
const SORT_KEY = "wf_sort";
const COLLAPSED_KEY = "wf_collapsed_json";
const SEARCH_KEY = "wf_search";
const CATEGORY_OVERRIDES_KEY = "wf_category_overrides_json";
function graph() {
return app.canvas?.getCurrentGraph?.() || app.graph;
}
function groups() {
const current = graph();
return [...(current?._groups || current?.groups || [])].filter((group) => group && group.title);
}
function groupTitle(group) {
return String(group?.title || "").trim();
}
function nameKey(value) {
return String(value || "").trim().toLocaleLowerCase();
}
function sameName(a, b) {
return nameKey(a) === nameKey(b);
}
function groupNames() {
return groups()
.map(groupTitle)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
}
function parseManaged(node) {
const raw = node.properties?.[STORE_KEY] || "[]";
try {
const values = JSON.parse(raw);
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
} catch (_err) {
// Reset broken data instead of blocking the UI.
}
return [];
}
function saveManaged(node, names) {
const unique = [];
const seen = new Set();
for (const name of names) {
const clean = String(name || "").trim();
const key = nameKey(clean);
if (!clean || seen.has(key)) {
continue;
}
seen.add(key);
unique.push(clean);
}
node.properties = node.properties || {};
node.properties[STORE_KEY] = JSON.stringify(unique);
if (!unique.includes(node.properties[ACTIVE_KEY])) {
node.properties[ACTIVE_KEY] = unique[0] || "";
}
return unique;
}
function managedNames(node) {
const names = parseManaged(node);
return names.length ? names : saveManaged(node, groupNames());
}
function findGroup(name) {
const wanted = nameKey(name);
return groups().find((group) => sameName(groupTitle(group), wanted)) || null;
}
function nodeIsControl(node) {
return node?.comfyClass === SWITCHBOARD_CLASS ||
node?.comfyClass === RETURN_CLASS ||
node?.type === "WF Switchboard" ||
node?.type === "WF Return";
}
function groupBounds(group) {
return group?._bounding ||
group?.bounding ||
[group?.pos?.[0] || 0, group?.pos?.[1] || 0, group?.size?.[0] || 0, group?.size?.[1] || 0];
}
function pointInBounds(x, y, bounds) {
return x >= bounds[0] && y >= bounds[1] && x <= bounds[0] + bounds[2] && y <= bounds[1] + bounds[3];
}
function nodeInGroup(node, group) {
const bounds = groupBounds(group);
const centerX = node.pos[0] + node.size[0] * 0.5;
const centerY = node.pos[1] + node.size[1] * 0.5;
return pointInBounds(centerX, centerY, bounds);
}
function groupInsideGroup(inner, outer) {
if (!inner || !outer || inner === outer) {
return false;
}
const innerBounds = groupBounds(inner);
const outerBounds = groupBounds(outer);
const centerX = innerBounds[0] + innerBounds[2] * 0.5;
const centerY = innerBounds[1] + innerBounds[3] * 0.5;
return pointInBounds(centerX, centerY, outerBounds);
}
function groupIsSelectedArea(group, selectedGroup) {
return group === selectedGroup || groupInsideGroup(group, selectedGroup);
}
function groupsInsideSelected(selectedGroup) {
return groups().filter((group) => groupIsSelectedArea(group, selectedGroup));
}
function uniqueNodes(nodes) {
const unique = [];
const seen = new Set();
for (const node of nodes) {
if (node && !seen.has(node)) {
seen.add(node);
unique.push(node);
}
}
return unique;
}
function nodesInGroup(group) {
if (!group) {
return [];
}
group.recomputeInsideNodes?.();
const children = Array.isArray(group._children) ? group._children : [];
const byBounds = (graph()?.nodes || []).filter((node) => nodeInGroup(node, group));
return uniqueNodes([...children, ...byBounds]);
}
function markGraphChanged() {
const current = graph();
current?.setDirtyCanvas?.(true, true);
current?.change?.();
if (typeof current?._version === "number") {
current._version++;
}
app.graph?.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
app.canvas?.draw?.(true, true);
app.canvas?.setDirtyCanvas?.(true, true);
}
function setNodeMode(node, mode) {
if (!node) {
return false;
}
const nextMode = Number(mode);
const changed = node.mode !== nextMode;
node.mode = nextMode;
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
return changed;
}
function setGroupMode(group, mode) {
let changed = 0;
group?.recomputeInsideNodes?.();
for (const node of nodesInGroup(group)) {
if (!nodeIsControl(node)) {
changed += setNodeMode(node, mode) ? 1 : 0;
}
}
group?.graph?.setDirtyCanvas?.(true, true);
return changed;
}
function showMessage(message) {
if (app.extensionManager?.toast) {
app.extensionManager.toast.add({ severity: "info", summary: "WF Switchboard", detail: message, life: 2600 });
} else {
console.log(`[WF Switchboard] ${message}`);
}
}
function focusThing(item) {
const canvas = app.canvas;
if (!canvas || !item) {
return;
}
if (canvas.centerOnNode) {
canvas.centerOnNode(item);
} else {
const rect = item._bounding || item.bounding || [item.pos?.[0] || 0, item.pos?.[1] || 0, item.size?.[0] || 300, item.size?.[1] || 200];
const scale = canvas.ds?.scale || 1;
canvas.ds.offset[0] = canvas.canvas.width / (2 * scale) - (rect[0] + rect[2] * 0.5);
canvas.ds.offset[1] = canvas.canvas.height / (2 * scale) - (rect[1] + rect[3] * 0.5);
}
const size = item._size || item.size || [item._bounding?.[2] || 900, item._bounding?.[3] || 600];
if (canvas.setZoom && size[0] > 0 && size[1] > 0) {
const current = canvas.ds?.scale || 1;
const fitX = canvas.canvas.width / size[0] - 0.04;
const fitY = canvas.canvas.height / size[1] - 0.04;
const target = Math.max(0.22, Math.min(current, fitX, fitY, 1.0));
canvas.setZoom(target, [canvas.canvas.width / 2, canvas.canvas.height / 2]);
}
canvas.setDirty?.(true, true);
}
function activateWorkflow(node, name, shouldFocus = true) {
const selectedName = String(name || node.properties?.[ACTIVE_KEY] || "").trim();
const managed = managedNames(node);
const selectedGroup = findGroup(selectedName);
if (!selectedName) {
showMessage("No workflow group selected.");
return;
}
if (!selectedGroup) {
showMessage(`Workflow group "${selectedName}" was not found.`);
return;
}
const activeName = groupTitle(selectedGroup) || selectedName;
const activeGroups = new Set(groupsInsideSelected(selectedGroup));
let changed = 0;
for (const managedName of managed) {
const group = findGroup(managedName);
if (!group || activeGroups.has(group)) {
continue;
}
setGroupMode(group, MODE_BYPASS);
changed++;
}
for (const group of activeGroups) {
setGroupMode(group, MODE_ALWAYS);
changed++;
}
node.properties[ACTIVE_KEY] = activeName;
node.title = `WF Switchboard: ${activeName}`;
node.__wfRefreshing = false;
markGraphChanged();
refreshSwitchboard(node);
setTimeout(() => {
markGraphChanged();
refreshSwitchboard(node);
}, 0);
if (shouldFocus && node.properties[AUTO_FOCUS_KEY] !== false && selectedGroup) {
focusThing(selectedGroup);
}
showMessage(`Enabled "${activeName}" plus inner groups, and bypassed the other workflow groups.`);
}
function focusSelected(node) {
const group = findGroup(node.properties?.[ACTIVE_KEY]);
if (group) {
focusThing(group);
}
}
function setAllManaged(node, mode) {
for (const name of managedNames(node)) {
const group = findGroup(name);
if (group) {
setGroupMode(group, mode);
}
}
markGraphChanged();
}
function chooseFromMenu(title, names, callback) {
if (!names.length) {
alert("No ComfyUI groups found in this graph.");
return;
}
const canvas = app.canvas;
const event = canvas?.last_mouse_event || window.event || undefined;
new LiteGraph.ContextMenu(
names.map((name) => ({ content: name })),
{
title,
event,
callback: (value) => callback(value?.content || value),
}
);
}
function addGroupToSwitchboard(node) {
const existing = new Set(managedNames(node).map(nameKey));
const available = groupNames().filter((name) => !existing.has(nameKey(name)));
chooseFromMenu("Add workflow group", available, (name) => {
const names = saveManaged(node, [...managedNames(node), name]);
node.properties[ACTIVE_KEY] = name;
refreshSwitchboard(node);
showMessage(`Added "${name}". ${names.length} workflow group(s) in the UI.`);
});
}
function removeActiveGroup(node) {
const active = node.properties?.[ACTIVE_KEY];
const remaining = managedNames(node).filter((name) => name !== active);
saveManaged(node, remaining);
refreshSwitchboard(node);
}
function rescanAllGroups(node) {
const names = saveManaged(node, groupNames());
node.properties[ACTIVE_KEY] = names[0] || "";
refreshSwitchboard(node);
showMessage(`Loaded ${names.length} group(s) into the switchboard.`);
}
function clearSwitchboardWidgets(node) {
if (!node.widgets) {
return;
}
node.widgets = node.widgets.filter((widget) => !widget.__wf_switchboard);
}
function addSwitchWidget(node, type, name, value, callback, options = {}) {
const widget = node.addWidget(type, name, value, callback, { ...options, serialize: false });
widget.__wf_switchboard = true;
widget.serialize = false;
widget.serializeValue = () => undefined;
return widget;
}
function fitText(ctx, text, width) {
const value = String(text || "");
if (ctx.measureText(value).width <= width) {
return value;
}
let low = 0;
let high = value.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(`${value.slice(0, mid)}...`).width <= width) {
low = mid;
} else {
high = mid - 1;
}
}
return `${value.slice(0, low)}...`;
}
function roundRect(ctx, x, y, width, height, radius = 5) {
const r = Math.min(radius, width * 0.5, height * 0.5);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawButton(ctx, rect, label, fill, stroke = "#5f6b7d") {
roundRect(ctx, rect.x, rect.y, rect.w, rect.h, 5);
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.stroke();
ctx.fillStyle = "#f2f5f8";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 11px sans-serif";
ctx.fillText(label, rect.x + rect.w * 0.5, rect.y + rect.h * 0.52);
}
function readJsonList(node, key) {
try {
const raw = node.properties?.[key];
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
} catch (_) {
return [];
}
}
function writeJsonList(node, key, values) {
node.properties = node.properties || {};
node.properties[key] = JSON.stringify(Array.from(new Set(values)));
}
function readJsonObject(node, key) {
try {
const raw = node.properties?.[key];
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
function writeJsonObject(node, key, value) {
node.properties = node.properties || {};
node.properties[key] = JSON.stringify(value || {});
}
function toggleListValue(node, key, value) {
const values = readJsonList(node, key);
const exists = values.some((entry) => sameName(entry, value));
writeJsonList(node, key, exists ? values.filter((entry) => !sameName(entry, value)) : [...values, value]);
}
const CATEGORY_ORDER = ["Image", "Video", "Upscale", "Edit", "Tools", "Custom"];
const CATEGORY_META = {
Image: { color: "#55b7e8", bg: "#143044", icon: "\u25A3" },
Video: { color: "#c08cff", bg: "#352247", icon: "\u25BA" },
Upscale: { color: "#5bd0cc", bg: "#153d3e", icon: "\u21F1" },
Edit: { color: "#f0c56d", bg: "#463719", icon: "\u270E" },
Tools: { color: "#ad8dff", bg: "#2f2548", icon: "\u25C7" },
Custom: { color: "#f18c9a", bg: "#44262d", icon: "\u2666" },
};
function categoryForName(name) {
const value = String(name || "").toLowerCase();
if (value.includes("upscale") || value.includes("super resolution") || value.includes("seedvr") || value.includes("rtx")) {
return "Upscale";
}
if (value.includes("edit") || value.includes("lora") || value.includes("qwen") || value.includes("flux")) {
return "Edit";
}
if (value.includes("tool") || value.includes("exr") || value.includes("audio") || value.includes("mmaudio")) {
return "Tools";
}
if (value.includes("video") || value.includes("wan") || value.includes("ltx")) {
return "Video";
}
if (value.includes("image") || value.includes("outpaint") || value.includes("frame interpolation")) {
return "Image";
}
return "Custom";
}
function categoryForWorkflow(node, name) {
const overrides = readJsonObject(node, CATEGORY_OVERRIDES_KEY);
const value = overrides[name];
return CATEGORY_ORDER.includes(value) ? value : categoryForName(name);
}
function setWorkflowCategory(node, name, category) {
const overrides = readJsonObject(node, CATEGORY_OVERRIDES_KEY);
const clean = String(category || "").trim();
if (!clean || clean.toLowerCase() === "auto") {
delete overrides[name];
} else {
const match = CATEGORY_ORDER.find((item) => item.toLowerCase() === clean.toLowerCase());
if (!match) {
showMessage(`Unknown category "${clean}". Use Image, Video, Upscale, Edit, Tools, Custom, or Auto.`);
return false;
}
overrides[name] = match;
}
writeJsonObject(node, CATEGORY_OVERRIDES_KEY, overrides);
return true;
}
function groupState(node, name) {
if (sameName(node.properties?.[ACTIVE_KEY], name)) {
return "USING";
}
const group = findGroup(name);
if (!group) {
return "Missing";
}
const managed = nodesInGroup(group).filter((item) => !nodeIsControl(item));
if (managed.length && managed.every((item) => item.mode === MODE_BYPASS)) {
return "Bypassed";
}
return "Ready";
}
function statusStyle(status) {
if (status === "USING") {
return { bg: "#1d4a20", stroke: "#79c96b", text: "#9eff8e" };
}
if (status === "Ready") {
return { bg: "#183a1e", stroke: "#427a3d", text: "#a0df8c" };
}
if (status === "Missing") {
return { bg: "#4a2026", stroke: "#89434d", text: "#ffb4bc" };
}
return { bg: "#30343b", stroke: "#555b66", text: "#c6cbd3" };
}
function drawPill(ctx, rect, label, style, font = "12px sans-serif") {
roundRect(ctx, rect.x, rect.y, rect.w, rect.h, Math.min(10, rect.h * 0.5));
ctx.fillStyle = style.bg;
ctx.fill();
ctx.strokeStyle = style.stroke || style.color || "#4a5668";
ctx.stroke();
ctx.fillStyle = style.text || style.color || "#e8eef6";
ctx.font = font;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, rect.x + rect.w * 0.5, rect.y + rect.h * 0.54);
}
function pointInRect(pos, rect) {
return pos[0] >= rect.x && pos[0] <= rect.x + rect.w && pos[1] >= rect.y && pos[1] <= rect.y + rect.h;
}
class WorkflowBoardWidget {
constructor(names) {
this.type = "custom";
this.name = "wf_switchboard_panel";
this.__wf_switchboard = true;
this.serialize = false;
this.names = names;
this.hitRects = [];
this.height = 620;
}
computeSize(width) {
return [width || 900, this.height];
}
addHit(type, rect, data = {}) {
this.hitRects.push({ type, rect, ...data });
}
layout(node) {
const filter = node.properties?.[CATEGORY_FILTER_KEY] || "All";
const sort = node.properties?.[SORT_KEY] || "A-Z";
const search = String(node.properties?.[SEARCH_KEY] || "").trim().toLowerCase();
const favorites = new Set(readJsonList(node, FAVORITES_KEY));
const collapsed = new Set(readJsonList(node, COLLAPSED_KEY));
let rows = this.names.map((name) => ({ name, category: categoryForWorkflow(node, name), favorite: favorites.has(name) }));
if (search) {
rows = rows.filter((row) => row.name.toLowerCase().includes(search));
}
if (filter === "Favorites") {
rows = rows.filter((row) => row.favorite);
} else if (CATEGORY_ORDER.includes(filter)) {
rows = rows.filter((row) => row.category === filter);
}
rows.sort((a, b) => (sort === "Z-A" ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)));
const sections = [];
const order = filter === "All" || filter === "Favorites" ? CATEGORY_ORDER : [filter];
for (const category of order) {
const categoryRows = rows.filter((row) => row.category === category);
if (categoryRows.length) {
sections.push({ category, collapsed: collapsed.has(category), rows: categoryRows });
}
}
return { filter, sort, search, favorites, collapsed, sections };
}
drawIconText(ctx, x, y, icon, label) {
ctx.fillStyle = "#e8eef6";
ctx.font = "19px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(icon, x, y);
ctx.font = "14px sans-serif";
ctx.fillText(label, x + 34, y);
}
drawRow(ctx, node, row, y, width, active) {
const x = 20;
const rowW = width - 40;
const h = 38;
const meta = CATEGORY_META[row.category] || CATEGORY_META.Custom;
const status = groupState(node, row.name);
const style = statusStyle(status);
const exists = status !== "Missing";
roundRect(ctx, x, y, rowW, h, 0);
ctx.fillStyle = active ? "rgba(89, 157, 83, 0.18)" : "rgba(255, 255, 255, 0.018)";
ctx.fill();
if (active) {
ctx.fillStyle = "#74d46f";
ctx.fillRect(x, y, 4, h);
}
ctx.strokeStyle = active ? "#6fb163" : "rgba(130, 146, 166, 0.22)";
ctx.beginPath();
ctx.moveTo(x, y + h);
ctx.lineTo(x + rowW, y + h);
ctx.stroke();
ctx.fillStyle = active ? "#9ef486" : "#dce4ed";
ctx.font = active ? "bold 14px sans-serif" : "14px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
const prefix = active ? "\u25B6 " : " ";
ctx.fillText(prefix + fitText(ctx, row.name, rowW - 480), x + 20, y + h * 0.55);
const categoryRect = { x: width - 460, y: y + 8, w: 72, h: 22 };
drawPill(ctx, categoryRect, row.category, {
bg: meta.bg,
stroke: meta.color,
text: "#d9f7ff",
}, "11px sans-serif");
drawPill(ctx, { x: width - 375, y: y + 8, w: 86, h: 22 }, status, style, "bold 11px sans-serif");
const useRect = { x: width - 270, y: y + 6, w: 82, h: 26 };
const goRect = { x: width - 176, y: y + 6, w: 78, h: 26 };
const favoriteRect = { x: width - 100, y: y + 3, w: 34, h: 32 };
const removeRect = { x: width - 58, y: y + 7, w: 42, h: 24 };
drawButton(ctx, useRect, "Use", active ? "#3f6b46" : exists ? "#273140" : "#37272b", active ? "#8ed49a" : "#5f6b7d");
drawButton(ctx, goRect, "Go", exists ? "#273140" : "#37272b", exists ? "#5f6b7d" : "#7d4a55");
ctx.fillStyle = row.favorite ? "#ffd35b" : "#b8c0ca";
ctx.font = "22px sans-serif";
ctx.textAlign = "center";
ctx.fillText(row.favorite ? "\u2605" : "\u2606", favoriteRect.x + favoriteRect.w * 0.5, y + h * 0.55);
drawButton(ctx, removeRect, "Del", "#50343b", "#a85b68");
this.addHit("use", useRect, { name: row.name });
this.addHit("focus", goRect, { name: row.name });
this.addHit("category", categoryRect, { name: row.name, category: row.category });
this.addHit("favorite", favoriteRect, { name: row.name });
this.addHit("remove", removeRect, { name: row.name });
}
draw(ctx, node, width, posY, height) {
const w = Math.max(width || 900, 860);
const { filter, sort, search, sections } = this.layout(node);
const active = node.properties?.[ACTIVE_KEY] || "";
const footerActive = active || "none";
const bypassedCount = this.names.filter((name) => groupState(node, name) === "Bypassed").length;
this.hitRects = [];
this.lastPosY = posY || 0;
ctx.save();
ctx.translate(0, posY);
const panel = { x: 8, y: 6, w: w - 16, h: height - 12 };
roundRect(ctx, panel.x, panel.y, panel.w, panel.h, 10);
const gradient = ctx.createLinearGradient(0, 0, 0, 110);
gradient.addColorStop(0, "#26344c");
gradient.addColorStop(1, "#131922");
ctx.fillStyle = gradient;
ctx.fill();
ctx.strokeStyle = "#3a4658";
ctx.stroke();
ctx.fillStyle = "#6d7786";
ctx.beginPath();
ctx.arc(28, 36, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#f0f4fa";
ctx.font = "22px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("WF Switchboard", 48, 32);
ctx.font = "13px sans-serif";
ctx.fillStyle = "#bac5d4";
ctx.fillText(`${active || "No active workflow"} - `, 48, 57);
ctx.fillStyle = "#81df72";
ctx.font = "bold 12px sans-serif";
ctx.fillText(active ? "USING" : "READY", 236, 57);
ctx.fillStyle = "#b7c0cc";
ctx.font = "22px sans-serif";
ctx.textAlign = "center";
ctx.fillText("i", w - 140, 35);
ctx.fillText("\u22EE", w - 92, 35);
ctx.fillText("\u2304", w - 52, 32);
const toolbar = [
["add", 24, "+", "Add group"],
["scan", 216, "\u27F3", "Scan"],
["bypass", 424, "\u2298", "Bypass all"],
["unbypass", 656, "\u2713", "Unbypass all"],
];
for (const [type, x, icon, label] of toolbar) {
const rect = { x, y: 86, w: type === "add" ? 170 : 190, h: 34 };
roundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fillStyle = "#202934";
ctx.fill();
ctx.strokeStyle = "#435061";
ctx.stroke();
this.drawIconText(ctx, rect.x + 22, rect.y + 17, icon, label);
this.addHit(type, rect);
}
const searchRect = { x: 24, y: 138, w: 330, h: 34 };
roundRect(ctx, searchRect.x, searchRect.y, searchRect.w, searchRect.h, 6);
ctx.fillStyle = "#11161d";
ctx.fill();
ctx.strokeStyle = "#414a56";
ctx.stroke();
ctx.fillStyle = "#a8b0ba";
ctx.font = "20px sans-serif";
ctx.textAlign = "left";
ctx.fillText("\u2315", searchRect.x + 18, searchRect.y + 18);
ctx.font = "13px sans-serif";
ctx.fillStyle = "#89919c";
ctx.fillText(search || "Search workflows...", searchRect.x + 46, searchRect.y + 18);
ctx.fillStyle = "#a8b0ba";
ctx.font = "18px sans-serif";
ctx.textAlign = "center";
ctx.fillText("x", searchRect.x + searchRect.w - 20, searchRect.y + 18);
this.addHit("search", searchRect);
this.addHit("clearSearch", { x: searchRect.x + searchRect.w - 36, y: searchRect.y + 4, w: 28, h: 26 });
drawPill(ctx, { x: 374, y: 138, w: 180, h: 34 }, filter, { bg: "#151b23", stroke: "#465161", text: "#eef4fb" }, "13px sans-serif");
this.addHit("cycleFilter", { x: 374, y: 138, w: 180, h: 34 });
drawPill(ctx, { x: 574, y: 138, w: 160, h: 34 }, sort, { bg: "#151b23", stroke: "#465161", text: "#eef4fb" }, "13px sans-serif");
this.addHit("sort", { x: 574, y: 138, w: 160, h: 34 });
ctx.fillStyle = "#cbd3df";
ctx.font = "13px sans-serif";
ctx.textAlign = "right";
ctx.fillText(`${this.names.length} workflows`, w - 34, 155);
const chips = ["All", ...CATEGORY_ORDER, "Favorites"];
let chipX = 24;
for (const chip of chips) {
const chipW = chip === "Favorites" ? 104 : Math.max(58, chip.length * 11 + 28);
const selected = filter === chip;
drawPill(ctx, { x: chipX, y: 190, w: chipW, h: 28 }, chip, {
bg: selected ? "#293545" : "#192029",
stroke: selected ? "#748397" : "#465161",
text: selected ? "#ffffff" : "#c0c7d1",
}, "13px sans-serif");
this.addHit("filter", { x: chipX, y: 190, w: chipW, h: 28 }, { filter: chip });
chipX += chipW + 10;
}
let y = 236;
for (const section of sections) {
const meta = CATEGORY_META[section.category] || CATEGORY_META.Custom;
const headerRect = { x: 20, y, w: w - 40, h: 34 };
roundRect(ctx, headerRect.x, headerRect.y, headerRect.w, headerRect.h, 6);
ctx.fillStyle = "rgba(255, 255, 255, 0.02)";
ctx.fill();
ctx.strokeStyle = "rgba(130, 146, 166, 0.22)";
ctx.stroke();
ctx.fillStyle = meta.color;
ctx.font = "16px sans-serif";
ctx.textAlign = "left";
ctx.fillText(section.collapsed ? "\u203A" : "\u2304", 30, y + 18);
ctx.fillText(meta.icon, 54, y + 18);
ctx.fillStyle = "#f1f4f8";
ctx.font = "bold 16px sans-serif";
ctx.fillText(`${section.category}`, 86, y + 18);
ctx.fillStyle = "#9ca6b4";
ctx.font = "13px sans-serif";
ctx.fillText(`(${section.rows.length})`, 150, y + 18);
ctx.textAlign = "center";
ctx.fillStyle = "#c5ceda";
ctx.font = "13px sans-serif";
ctx.fillText("Use", w - 229, y + 18);
ctx.fillText("Go", w - 137, y + 18);
ctx.fillText("Fav", w - 83, y + 18);
ctx.fillText("Del", w - 37, y + 18);
this.addHit("collapse", headerRect, { category: section.category });
y += 34;
if (!section.collapsed) {
const maxRows = filter === "All" ? 5 : 12;
const visible = section.rows.slice(0, maxRows);
for (const row of visible) {
this.drawRow(ctx, node, row, y, w, sameName(active, row.name));
y += 38;
}
if (visible.length < section.rows.length) {
ctx.fillStyle = "#aeb7c4";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`Show all ${section.rows.length} ${section.category.toLowerCase()} workflows...`, w * 0.5, y + 17);
this.addHit("filter", { x: 200, y: y + 2, w: w - 400, h: 28 }, { filter: section.category });
y += 34;
}
}
y += 6;
}
if (!sections.length) {
ctx.fillStyle = "#9ba5b3";
ctx.font = "15px sans-serif";
ctx.textAlign = "center";
ctx.fillText("No workflow groups in this filter.", w * 0.5, y + 42);
y += 80;
}
const footer = { x: 20, y: Math.max(y + 14, height - 52), w: w - 40, h: 36 };
roundRect(ctx, footer.x, footer.y, footer.w, footer.h, 8);
ctx.fillStyle = "#1b232d";
ctx.fill();
ctx.strokeStyle = "#3d4857";
ctx.stroke();
ctx.fillStyle = "#aeb8c6";
ctx.font = "13px sans-serif";
ctx.textAlign = "left";
ctx.fillText("Active:", footer.x + 14, footer.y + 19);
ctx.fillStyle = "#72cf77";
ctx.beginPath();
ctx.arc(footer.x + 76, footer.y + 18, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#d8e1eb";
ctx.fillText(fitText(ctx, footerActive, 460), footer.x + 88, footer.y + 19);
ctx.fillStyle = "#b7c0cc";
ctx.textAlign = "right";
ctx.fillText(`Bypassed: ${bypassedCount}`, footer.x + footer.w - 16, footer.y + 19);
ctx.restore();
}
mouse(event, pos, node) {
if (event.type !== "pointerdown") {
return false;
}
const localPos = this.lastPosY ? [pos[0], pos[1] - this.lastPosY] : pos;
const hits = [...this.hitRects].reverse();
const hit = hits.find((item) => pointInRect(localPos, item.rect)) || hits.find((item) => pointInRect(pos, item.rect));
if (!hit) {
return true;
}
if (hit.type === "add") {
addGroupToSwitchboard(node);
} else if (hit.type === "scan") {
rescanAllGroups(node);
} else if (hit.type === "search") {
const value = window.prompt?.("Search workflows", node.properties?.[SEARCH_KEY] || "");
if (value != null) {
node.properties[SEARCH_KEY] = value;
refreshSwitchboard(node);
}
} else if (hit.type === "clearSearch") {
node.properties[SEARCH_KEY] = "";
refreshSwitchboard(node);
} else if (hit.type === "bypass") {
setAllManaged(node, MODE_BYPASS);
} else if (hit.type === "unbypass") {
setAllManaged(node, MODE_ALWAYS);
} else if (hit.type === "filter") {
node.properties[CATEGORY_FILTER_KEY] = hit.filter;
refreshSwitchboard(node);
} else if (hit.type === "cycleFilter") {
const choices = ["All", ...CATEGORY_ORDER, "Favorites"];
const current = node.properties[CATEGORY_FILTER_KEY] || "All";
node.properties[CATEGORY_FILTER_KEY] = choices[(choices.indexOf(current) + 1) % choices.length] || "All";
refreshSwitchboard(node);
} else if (hit.type === "sort") {
node.properties[SORT_KEY] = (node.properties[SORT_KEY] || "A-Z") === "A-Z" ? "Z-A" : "A-Z";
refreshSwitchboard(node);
} else if (hit.type === "collapse") {
toggleListValue(node, COLLAPSED_KEY, hit.category);
refreshSwitchboard(node);
} else if (hit.type === "category") {
const value = window.prompt?.(`Category for "${hit.name}"\nImage, Video, Upscale, Edit, Tools, Custom, or Auto`, hit.category || "Auto");
if (value != null && setWorkflowCategory(node, hit.name, value)) {
refreshSwitchboard(node);
}
} else if (hit.type === "favorite") {
toggleListValue(node, FAVORITES_KEY, hit.name);
refreshSwitchboard(node);
} else if (hit.type === "use") {
activateWorkflow(node, hit.name, true);
} else if (hit.type === "focus") {
const group = findGroup(hit.name);
if (group) {
focusThing(group);
}
} else if (hit.type === "remove") {
const remaining = managedNames(node).filter((name) => name !== hit.name);
saveManaged(node, remaining);
refreshSwitchboard(node);
}
return true;
}
}
function addCustomSwitchWidget(node, widget) {
node.addCustomWidget(widget);
return widget;
}
function refreshSwitchboard(node) {
if (node.__wfRefreshing) {
return;
}
node.__wfRefreshing = true;
try {
clearSwitchboardWidgets(node);
node.properties = node.properties || {};
if (node.properties[AUTO_FOCUS_KEY] == null) {
node.properties[AUTO_FOCUS_KEY] = true;
}
const names = managedNames(node);
const active = names.find((name) => sameName(name, node.properties[ACTIVE_KEY])) || names[0] || "";
node.properties[ACTIVE_KEY] = active;
if (!node.properties[CATEGORY_FILTER_KEY]) {
node.properties[CATEGORY_FILTER_KEY] = "All";
}
if (!node.properties[SORT_KEY]) {
node.properties[SORT_KEY] = "A-Z";
}
const board = new WorkflowBoardWidget(names);
const rowsByCategory = board.layout(node).sections.reduce((sum, section) => {
const maxRows = node.properties[CATEGORY_FILTER_KEY] === "All" ? 5 : 12;
const visible = Math.min(section.rows.length, maxRows);
return sum + 1 + (section.collapsed ? 0 : visible) + (!section.collapsed && node.properties[CATEGORY_FILTER_KEY] === "All" && section.rows.length > visible ? 1 : 0);
}, 0);
board.height = Math.max(390, 254 + rowsByCategory * 40 + 70);
addCustomSwitchWidget(node, board);
node.title = "WF Switchboard";
node.color = "#28364f";
node.bgcolor = "#121922";
node.size = [900, board.height + 16];
node.setDirtyCanvas?.(true, true);
} finally {
node.__wfRefreshing = false;
}
}
function firstSwitchboard() {
return (graph()?.nodes || []).find((node) => node?.comfyClass === SWITCHBOARD_CLASS || node?.type === "WF Switchboard") || null;
}
function groupContainingNode(node) {
return groups().find((group) => nodesInGroup(group).includes(node)) || null;
}
function setupReturnNode(node) {
node.properties = node.properties || {};
node.color = "#2c3828";
node.bgcolor = "#1d261c";
node.title = "WF Return";
clearSwitchboardWidgets(node);
addSwitchWidget(node, "button", "Back to main UI", null, () => {
const switchboard = firstSwitchboard();
if (switchboard) {
focusThing(switchboard);
} else {
showMessage("No WF Switchboard node found.");
}
});
node.size = [230, 62];
}
app.registerExtension({
name: "Penzilla.WorkflowSwitchboard",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === SWITCHBOARD_CLASS) {
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = originalCreated?.apply(this, arguments);
setTimeout(() => refreshSwitchboard(this), 0);
return result;
};
const originalConfigured = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = originalConfigured?.apply(this, arguments);
setTimeout(() => refreshSwitchboard(this), 0);
return result;
};
}
if (nodeData.name === RETURN_CLASS) {
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = originalCreated?.apply(this, arguments);
setTimeout(() => setupReturnNode(this), 0);
return result;
};
const originalConfigured = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = originalConfigured?.apply(this, arguments);
setTimeout(() => setupReturnNode(this), 0);
return result;
};
}
},
loadedGraphNode(node) {
if (node.comfyClass === SWITCHBOARD_CLASS || node.type === "WF Switchboard") {
setTimeout(() => refreshSwitchboard(node), 0);
}
if (node.comfyClass === RETURN_CLASS || node.type === "WF Return") {
setTimeout(() => setupReturnNode(node), 0);
}
},
});