Files
Maxed-Out-99 2b5db05b71 Bump version; fix Combo++ folder ordering
Bump package version to 1.0.7. Update web/js/bettercombos.js to rename the extension and setting IDs from "pysssss" to "smlmxd". Rework folder rendering so subfolders are inserted before items (folders appear above files) and root files are appended after folders. Minor comment adjustments to reflect the new insertion order.
2026-02-27 03:58:04 -08:00

444 lines
17 KiB
JavaScript

import { app } from "../../../../scripts/app.js";
import { $el } from "../../../../scripts/ui.js";
import { api } from "../../../../scripts/api.js";
const UNET_LOADER_UNIFIED = "UNETLoaderUnified";
const CLIP_LOADER_UNIFIED = "CLIPLoaderUnified";
const IMAGE_WIDTH = 384;
const IMAGE_HEIGHT = 384;
const NODE_CONFIGS = {
[UNET_LOADER_UNIFIED]: { type: "unet", widgetName: "unet_name", hasImages: false },
[CLIP_LOADER_UNIFIED]: { type: "clip", widgetName: "clip_name1", hasImages: false },
};
const CONFIG_BY_TYPE = Object.fromEntries(Object.values(NODE_CONFIGS).map((config) => [config.type, config]));
const getNodeConfig = (nodeOrClass) => {
const comfyClass = typeof nodeOrClass === "string" ? nodeOrClass : nodeOrClass?.comfyClass;
return comfyClass ? NODE_CONFIGS[comfyClass] : undefined;
};
function encodeRFC3986URIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
const calculateImagePosition = (el, bodyRect) => {
let { top, left, right } = el.getBoundingClientRect();
const { width: bodyWidth, height: bodyHeight } = bodyRect;
const isSpaceRight = right + IMAGE_WIDTH <= bodyWidth;
if (isSpaceRight) {
left = right;
} else {
left -= IMAGE_WIDTH;
}
top = top - IMAGE_HEIGHT / 2;
if (top + IMAGE_HEIGHT > bodyHeight) {
top = bodyHeight - IMAGE_HEIGHT;
}
if (top < 0) {
top = 0;
}
return { left: Math.round(left), top: Math.round(top), isLeft: !isSpaceRight };
};
function showImage(relativeToEl, imageEl) {
const bodyRect = document.body.getBoundingClientRect();
if (!bodyRect) return;
const { left, top, isLeft } = calculateImagePosition(relativeToEl, bodyRect);
imageEl.style.left = `${left}px`;
imageEl.style.top = `${top}px`;
if (isLeft) {
imageEl.classList.add("left");
} else {
imageEl.classList.remove("left");
}
document.body.appendChild(imageEl);
}
const imagesByType = {};
const imagePromises = {};
const loadImageList = async (type) => {
imagesByType[type] = await (await api.fetchApi(`/pysssss/images/${type}`)).json();
return imagesByType[type];
};
const ensureImageList = (type) => {
const config = CONFIG_BY_TYPE[type];
if (!config?.hasImages) {
return Promise.resolve();
}
if (!imagePromises[type]) {
imagePromises[type] = loadImageList(type);
}
return imagePromises[type];
};
for (const type of Object.keys(CONFIG_BY_TYPE)) {
imagesByType[type] = {};
}
app.registerExtension({
name: "smlmxd.Combo++",
init() {
const displayOptions = { "List (normal)": 0, "Tree (subfolders)": 1, "Thumbnails (grid)": 2 };
const displaySetting = app.ui.settings.addSetting({
id: "smlmxd.Combo++.Submenu",
name: "🐍 Loader display mode (Smart UNet/CLIP)",
defaultValue: 1,
type: "combo",
options: (value) => {
value = +value;
return Object.entries(displayOptions).map(([k, v]) => ({
value: v,
text: k,
selected: k === value,
}));
},
});
$el("style", {
textContent: `
.pysssss-combo-image {
position: absolute;
left: 0;
top: 0;
width: ${IMAGE_WIDTH}px;
height: ${IMAGE_HEIGHT}px;
object-fit: contain;
object-position: top left;
z-index: 9999;
}
.pysssss-combo-image.left {
object-position: top right;
}
.pysssss-combo-folder { opacity: 0.7 }
.pysssss-combo-folder-arrow { display: inline-block; width: 15px; }
.pysssss-combo-folder:hover { background-color: rgba(255, 255, 255, 0.1); }
.pysssss-combo-prefix { display: none }
/* Special handling for when the filter input is populated to revert to normal */
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-folder-contents {
display: block !important;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-folder {
display: none;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .pysssss-combo-prefix {
display: inline;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .litemenu-entry {
padding-left: 2px !important;
}
/* Grid mode */
.pysssss-combo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
overflow-x: hidden;
max-width: 60vw;
}
.pysssss-combo-grid .comfy-context-menu-filter {
grid-column: 1 / -1;
position: sticky;
top: 0;
}
.pysssss-combo-grid .litemenu-entry {
word-break: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
.pysssss-combo-grid .litemenu-entry:before {
content: "";
display: block;
width: 100%;
height: 250px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
/* No-image image attribution: Picture icons created by Pixel perfect - Flaticon */
background-image: var(--background-image, url(extensions/ComfyUI-Custom-Scripts/js/assets/no-image.png));
}
`,
parent: document.body,
});
const imageTypes = Object.keys(CONFIG_BY_TYPE).filter((type) => CONFIG_BY_TYPE[type].hasImages);
for (const type of imageTypes) {
ensureImageList(type).catch(() => {});
}
const refreshComboInNodes = app.refreshComboInNodes;
app.refreshComboInNodes = async function () {
const r = await Promise.all([
refreshComboInNodes.apply(this, arguments),
...imageTypes.map((type) => ensureImageList(type).catch(() => {})),
]);
return r[0];
};
const imageHost = $el("img.pysssss-combo-image");
const positionMenu = (menu, fillWidth) => {
// compute best position
let left = app.canvas.last_mouse[0] - 10;
let top = app.canvas.last_mouse[1] - 10;
const body_rect = document.body.getBoundingClientRect();
const root_rect = menu.getBoundingClientRect();
if (body_rect.width && left > body_rect.width - root_rect.width - 10) left = body_rect.width - root_rect.width - 10;
if (body_rect.height && top > body_rect.height - root_rect.height - 10) top = body_rect.height - root_rect.height - 10;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
if (fillWidth) {
menu.style.right = "10px";
}
};
const updateMenu = async (menu, config) => {
const { type } = config;
try {
await ensureImageList(type);
} catch (error) {
console.error(error);
console.error("Error loading pysssss.betterCombos data");
}
// Clamp max height so it doesn't overflow the screen
const position = menu.getBoundingClientRect();
const maxHeight = window.innerHeight - position.top - 20;
menu.style.maxHeight = `${maxHeight}px`;
const images = imagesByType[type] || {};
const items = menu.querySelectorAll(".litemenu-entry");
// Add image handler to items
const addImageHandler = (item) => {
const text = item.getAttribute("data-value").trim();
if (images[text]) {
const textNode = document.createTextNode("*");
item.appendChild(textNode);
item.addEventListener(
"mouseover",
() => {
imageHost.src = `/pysssss/view/${encodeRFC3986URIComponent(images[text])}?${+new Date()}`;
document.body.appendChild(imageHost);
showImage(item, imageHost);
},
{ passive: true }
);
item.addEventListener(
"mouseout",
() => {
imageHost.remove();
},
{ passive: true }
);
item.addEventListener(
"click",
() => {
imageHost.remove();
},
{ passive: true }
);
}
};
const createTree = () => {
// Create a map to store folder structures
const folderMap = new Map();
const rootItems = [];
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//;
const itemsSymbol = Symbol("items");
// First pass - organize items into folder structure
for (const item of items) {
let path = item
.getAttribute("data-value")
.split(splitBy)
.filter((segment) => segment.length);
if (type === "latents") {
const latentsIndex = path.findIndex((segment) => segment.toLowerCase() === "latents");
if (latentsIndex !== -1) {
path = path.slice(latentsIndex + 1);
}
}
if (!path.length) {
item.remove();
continue;
}
// Remove path from visible text
item.textContent = path[path.length - 1];
if (path.length > 1) {
// Add the prefix path back in so it can be filtered on
const prefix = $el("span.pysssss-combo-prefix", {
textContent: path.slice(0, -1).join("/") + "/",
});
item.prepend(prefix);
}
addImageHandler(item);
if (path.length === 1) {
rootItems.push(item);
continue;
}
// Temporarily remove the item from current position
item.remove();
// Create folder hierarchy
let currentLevel = folderMap;
for (let i = 0; i < path.length - 1; i++) {
const folder = path[i];
if (!currentLevel.has(folder)) {
currentLevel.set(folder, new Map());
}
currentLevel = currentLevel.get(folder);
}
// Store the actual item in the deepest folder
if (!currentLevel.has(itemsSymbol)) {
currentLevel.set(itemsSymbol, []);
}
currentLevel.get(itemsSymbol).push(item);
}
const createFolderElement = (name) => {
const folder = $el("div.litemenu-entry.pysssss-combo-folder", {
innerHTML: `<span class="pysssss-combo-folder-arrow">▶</span> ${name}`,
style: { paddingLeft: "5px" },
});
return folder;
};
const insertFolderStructure = (parentElement, map, level = 0) => {
for (const [folderName, content] of map.entries()) {
if (folderName === itemsSymbol) continue;
const folderElement = createFolderElement(folderName);
folderElement.style.paddingLeft = `${level * 10 + 5}px`;
parentElement.appendChild(folderElement);
const childContainer = $el("div.pysssss-combo-folder-contents", {
style: { display: "none" },
});
// Add subfolders first so folders appear above files
insertFolderStructure(childContainer, content, level + 1);
// Add items in this folder after subfolders
const items = content.get(itemsSymbol) || [];
for (const item of items) {
item.style.paddingLeft = `${(level + 1) * 10 + 14}px`;
childContainer.appendChild(item);
}
parentElement.appendChild(childContainer);
// Add click handler for folder
folderElement.addEventListener("click", (e) => {
e.stopPropagation();
const arrow = folderElement.querySelector(".pysssss-combo-folder-arrow");
const contents = folderElement.nextElementSibling;
if (contents.style.display === "none") {
contents.style.display = "block";
arrow.textContent = "▼";
} else {
contents.style.display = "none";
arrow.textContent = "▶";
}
});
}
};
const parentElement = items[0]?.parentElement || menu;
insertFolderStructure(parentElement, folderMap);
// Move root files after folders so folders show at the top level first
for (const item of rootItems) {
parentElement.appendChild(item);
}
positionMenu(menu);
};
const addImageData = (item) => {
const text = item.getAttribute("data-value").trim();
if (images[text]) {
item.style.setProperty("--background-image", `url(/pysssss/view/${encodeRFC3986URIComponent(images[text])})`);
}
};
if (displaySetting.value === 1 || displaySetting.value === true) {
createTree();
} else if (displaySetting.value === 2) {
menu.classList.add("pysssss-combo-grid");
for (const item of items) {
addImageData(item);
}
positionMenu(menu, true);
} else {
for (const item of items) {
addImageHandler(item);
}
}
};
const mutationObserver = new MutationObserver((mutations) => {
const node = app.canvas.current_node;
const config = getNodeConfig(node);
if (!node || !config) {
return;
}
for (const mutation of mutations) {
for (const removed of mutation.removedNodes) {
if (removed.classList?.contains("litecontextmenu")) {
imageHost.remove();
}
}
for (const added of mutation.addedNodes) {
if (added.classList?.contains("litecontextmenu")) {
const overWidget = app.canvas.getWidgetAtCursor();
if (overWidget?.name === config.widgetName) {
requestAnimationFrame(() => {
// Bad hack to prevent showing on right click menu by checking for the filter input
if (!added.querySelector(".comfy-context-menu-filter")) return;
updateMenu(added, config);
});
}
return;
}
}
}
});
mutationObserver.observe(document.body, { childList: true, subtree: false });
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
return getExtraMenuOptions?.apply(this, arguments);
};
},
});