flatcherlee's picture
Upload 2334 files
3d5837a verified
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
import { $el } from "../../../scripts/ui.js";
import { api } from "../../../scripts/api.js";
const CHECKPOINT_LOADER = "CheckpointLoader|pysssss";
const LORA_LOADER = "LoraLoader|pysssss";
function getType(node) {
if (node.comfyClass === CHECKPOINT_LOADER) {
return "checkpoints";
}
return "loras";
}
app.registerExtension({
name: "pysssss.Combo++",
init() {
$el("style", {
textContent: `
.litemenu-entry:hover .pysssss-combo-image {
display: block;
}
.pysssss-combo-image {
display: none;
position: absolute;
left: 0;
top: 0;
transform: translate(-100%, 0);
width: 384px;
height: 384px;
background-size: contain;
background-position: top right;
background-repeat: no-repeat;
filter: brightness(65%);
}
`,
parent: document.body,
});
const submenuSetting = app.ui.settings.addSetting({
id: "pysssss.Combo++.Submenu",
name: "🐍 Enable submenu in custom nodes",
defaultValue: true,
type: "boolean",
});
// Ensure hook callbacks are available
const getOrSet = (target, name, create) => {
if (name in target) return target[name];
return (target[name] = create());
};
const symbol = getOrSet(window, "__pysssss__", () => Symbol("__pysssss__"));
const store = getOrSet(window, symbol, () => ({}));
const contextMenuHook = getOrSet(store, "contextMenuHook", () => ({}));
for (const e of ["ctor", "preAddItem", "addItem"]) {
if (!contextMenuHook[e]) {
contextMenuHook[e] = [];
}
}
// // Checks if this is a custom combo item
const isCustomItem = (value) => value && typeof value === "object" && "image" in value && value.content;
// Simple check for what separator to split by
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//;
contextMenuHook["ctor"].push(function (values, options) {
// Copy the class from the parent so if we are dark we are also dark
// this enables the filter box
if (options.parentMenu?.options?.className === "dark") {
options.className = "dark";
}
});
function encodeRFC3986URIComponent(str) {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
// After an element is created for an item, add an image if it has one
contextMenuHook["addItem"].push(function (el, menu, [name, value, options]) {
if (el && isCustomItem(value) && value?.image && !value.submenu) {
el.textContent += " *";
$el("div.pysssss-combo-image", {
parent: el,
style: {
backgroundImage: `url(/pysssss/view/${encodeRFC3986URIComponent(value.image)})`,
},
});
}
});
function buildMenu(widget, values) {
const lookup = {
"": { options: [] },
};
// Split paths into menu structure
for (const value of values) {
const split = value.content.split(splitBy);
let path = "";
for (let i = 0; i < split.length; i++) {
const s = split[i];
const last = i === split.length - 1;
if (last) {
// Leaf node, manually add handler that sets the lora
lookup[path].options.push({
...value,
title: s,
callback: () => {
widget.value = value;
widget.callback(value);
app.graph.setDirtyCanvas(true);
},
});
} else {
const prevPath = path;
path += s + splitBy;
if (!lookup[path]) {
const sub = {
title: s,
submenu: {
options: [],
title: s,
},
};
// Add to tree
lookup[path] = sub.submenu;
lookup[prevPath].options.push(sub);
}
}
}
}
return lookup[""].options;
}
// Override COMBO widgets to patch their values
const combo = ComfyWidgets["COMBO"];
ComfyWidgets["COMBO"] = function (node, inputName, inputData) {
const type = inputData[0];
const res = combo.apply(this, arguments);
if (isCustomItem(type[0])) {
let value = res.widget.value;
let values = res.widget.options.values;
let menu = null;
// Override the option values to check if we should render a menu structure
Object.defineProperty(res.widget.options, "values", {
get() {
let v = values;
if (submenuSetting.value) {
if (!menu) {
// Only build the menu once
menu = buildMenu(res.widget, values);
}
v = menu;
}
const valuesIncludes = v.includes;
v.includes = function (searchElement) {
const includesFromMenuItems = function (items) {
for (const item of items) {
if (includesFromMenuItem(item)) {
return true;
}
}
return false;
}
const includesFromMenuItem = function (item) {
if (item.submenu) {
return includesFromMenuItems(item.submenu.options)
} else {
return item.content === searchElement.content;
}
}
const includes = valuesIncludes.apply(this, arguments) || includesFromMenuItems(this);
return includes;
}
return v;
},
set(v) {
// Options are changing (refresh) so reset the menu so it can be rebuilt if required
values = v;
menu = null;
},
});
Object.defineProperty(res.widget, "value", {
get() {
// HACK: litegraph supports rendering items with "content" in the menu, but not on the widget
// This detects when its being called by the widget drawing and just returns the text
// Also uses the content for the same image replacement value
if (res.widget) {
const stack = new Error().stack;
if (stack.includes("drawNodeWidgets") || stack.includes("saveImageExtraOutput")) {
return (value || type[0]).content;
}
}
return value;
},
set(v) {
if (v?.submenu) {
// Dont allow selection of submenus
return;
}
value = v;
},
});
}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const isCkpt = nodeType.comfyClass === CHECKPOINT_LOADER;
const isLora = nodeType.comfyClass === LORA_LOADER;
if (isCkpt || isLora) {
const onAdded = nodeType.prototype.onAdded;
nodeType.prototype.onAdded = function () {
onAdded?.apply(this, arguments);
const { widget: exampleList } = ComfyWidgets["COMBO"](this, "example", [[""]], app);
let exampleWidget;
const get = async (route, suffix) => {
const url = encodeURIComponent(`${getType(nodeType)}${suffix || ""}`);
return await api.fetchApi(`/pysssss/${route}/${url}`);
};
const getExample = async () => {
if (exampleList.value === "[none]") {
if (exampleWidget) {
exampleWidget.inputEl.remove();
exampleWidget = null;
this.widgets.length -= 1;
}
return;
}
const v = this.widgets[0].value.content;
const pos = v.lastIndexOf(".");
const name = v.substr(0, pos);
let exampleName = exampleList.value;
let viewPath = `/${name}`;
if (exampleName === "notes") {
viewPath += ".txt";
} else {
viewPath += `/${exampleName}`;
}
const example = await (await get("view", viewPath)).text();
if (!exampleWidget) {
exampleWidget = ComfyWidgets["STRING"](this, "prompt", ["STRING", { multiline: true }], app).widget;
exampleWidget.inputEl.readOnly = true;
exampleWidget.inputEl.style.opacity = 0.6;
}
exampleWidget.value = example;
};
const exampleCb = exampleList.callback;
exampleList.callback = function () {
getExample();
return exampleCb?.apply(this, arguments) ?? exampleList.value;
};
const listExamples = async () => {
exampleList.disabled = true;
exampleList.options.values = ["[none]"];
exampleList.value = "[none]";
let examples = [];
if (this.widgets[0].value?.content) {
try {
examples = await (await get("examples", `/${this.widgets[0].value.content}`)).json();
} catch (error) {}
}
exampleList.options.values = ["[none]", ...examples];
exampleList.value = exampleList.options.values[+!!examples.length];
exampleList.callback();
exampleList.disabled = !examples.length;
app.graph.setDirtyCanvas(true, true);
};
// Expose function to update examples
nodeType.prototype["pysssss.updateExamples"] = listExamples;
const modelWidget = this.widgets[0];
const modelCb = modelWidget.callback;
let prev = undefined;
modelWidget.callback = function () {
const ret = modelCb?.apply(this, arguments) ?? modelWidget.value;
let v = ret;
if (ret?.content) {
v = ret.content;
}
if (prev !== v) {
listExamples();
prev = v;
}
return ret;
};
setTimeout(() => {
modelWidget.callback();
}, 30);
};
// Prevent adding HIDDEN inputs
const addInput = nodeType.prototype.addInput ?? LGraphNode.prototype.addInput;
nodeType.prototype.addInput = function (_, type) {
if (type === "HIDDEN") return;
return addInput.apply(this, arguments);
};
}
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (this.imgs) {
// If this node has images then we add an open in new tab item
let img;
if (this.imageIndex != null) {
// An image is selected so select that
img = this.imgs[this.imageIndex];
} else if (this.overIndex != null) {
// No image is selected but one is hovered
img = this.imgs[this.overIndex];
}
if (img) {
const nodes = app.graph._nodes.filter(
(n) => n.comfyClass === LORA_LOADER || n.comfyClass === CHECKPOINT_LOADER
);
if (nodes.length) {
options.unshift({
content: "Save as Preview",
submenu: {
options: nodes.map((n) => ({
content: n.widgets[0].value.content,
callback: async () => {
const url = new URL(img.src);
const { image } = await api.fetchApi(
"/pysssss/save/" + encodeURIComponent(`${getType(n)}/${n.widgets[0].value.content}`),
{
method: "POST",
body: JSON.stringify({
filename: url.searchParams.get("filename"),
subfolder: url.searchParams.get("subfolder"),
type: url.searchParams.get("type"),
}),
headers: {
"content-type": "application/json",
},
}
);
n.widgets[0].value.image = image;
app.refreshComboInNodes();
},
})),
},
});
}
}
}
return getExtraMenuOptions?.apply(this, arguments);
};
},
});