Files
Maxed-Out-99-ComfyUI-MaxedOut/web/lib/mxd_utils_menu.js
T
Maxed-Out-99andClaude Fable 5 4cb19be2e0 Restructure web/ into lib+nodes+vendor; merge power lora loader forks
- web/lib/: the shared rgthree-derived layer (mxd_* utils, dialogs, api,
  runtime) + its CSS, moved as a unit.
- web/nodes/: one extension file per node/feature, _mxd suffix dropped.
- web/vendor/zip_loader/: vendored zip import addon.
- index.js explicitly imports every module; loraloader_mxd_entry.js
  folded into it and deleted.

Power Lora Loader merge: new web/nodes/power_lora_base.js holds the
shared node machinery, header row, and single-strength lora row widget.
power_lora_loader.js (dual model/clip strengths) and
ltx2_power_lora_loader.js (per-layer strength rows) are now thin
subclasses (~450 duplicated lines removed). Serialization formats,
widget names, extension names, and node targets unchanged.

better_combos.js scoped to MXD latent loaders only: removed the
pysssss CheckpointLoader/LoraLoader patching (already disabled behind
ENABLE_PYSSSS_NODE_ENHANCEMENTS=false, so provably inert), the dead
' MXD' alias config entries, and the no-op refreshComboInNodes wrapper.

scripts/check_web.py clean; node schema unchanged (68 identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:41:35 -07:00

215 lines
6.9 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { mxdApi } from "./mxd_api.js";
import { isSmartMatch } from "./mxd_smart_search.js";
let chooserStylesInjected = false;
function splitPathSegments(value) {
return String(value)
.split(/[\\/]+/)
.map((s) => s.trim())
.filter(Boolean);
}
function ensureChooserTreeStyles() {
if (chooserStylesInjected) return;
chooserStylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.mxd-chooser-folder { opacity: 0.8; }
.mxd-chooser-folder-arrow { display: inline-block; width: 15px; }
.mxd-chooser-folder:hover { background-color: rgba(255, 255, 255, 0.1); }
.mxd-chooser-prefix { display: none; }
.litecontextmenu:has(input:not(:placeholder-shown)) .mxd-chooser-folder-contents {
display: block !important;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .mxd-chooser-folder {
display: none;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .mxd-chooser-prefix {
display: inline;
}
.litecontextmenu:has(input:not(:placeholder-shown)) .litemenu-entry {
padding-left: 2px !important;
}
`;
document.body.appendChild(style);
}
function getContextMenuRootElement(menuInstance) {
const menus = [...document.querySelectorAll(".litecontextmenu")];
return (
menuInstance?.root ||
menuInstance?.rootElement ||
menuInstance?.element ||
menus[menus.length - 1] ||
null
);
}
function decorateChooserAsTree(menuEl) {
if (!menuEl || menuEl.dataset.mxdChooserTreeApplied === "1") {
return;
}
menuEl.dataset.mxdChooserTreeApplied = "1";
const items = [...menuEl.querySelectorAll(".litemenu-entry")];
if (!items.length) {
return;
}
const folderMap = new Map();
const rootItems = [];
const itemsSymbol = Symbol("items");
for (const item of items) {
const dataValue = item.getAttribute("data-value") ?? item.textContent ?? "";
const path = splitPathSegments(dataValue);
if (!path.length) {
continue;
}
item.textContent = path[path.length - 1];
if (path.length > 1) {
const prefix = document.createElement("span");
prefix.className = "mxd-chooser-prefix";
prefix.textContent = path.slice(0, -1).join("/") + "/";
item.prepend(prefix);
}
if (path.length === 1) {
rootItems.push(item);
continue;
}
item.remove();
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);
}
if (!currentLevel.has(itemsSymbol)) {
currentLevel.set(itemsSymbol, []);
}
currentLevel.get(itemsSymbol).push(item);
}
const createFolderElement = (name) => {
const folder = document.createElement("div");
folder.className = "litemenu-entry mxd-chooser-folder";
folder.style.paddingLeft = "5px";
folder.innerHTML = `<span class="mxd-chooser-folder-arrow">></span> ${name}`;
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 = document.createElement("div");
childContainer.className = "mxd-chooser-folder-contents";
childContainer.style.display = "none";
const childItems = content.get(itemsSymbol) || [];
for (const item of childItems) {
item.style.paddingLeft = `${(level + 1) * 10 + 14}px`;
childContainer.appendChild(item);
}
insertFolderStructure(childContainer, content, level + 1);
parentElement.appendChild(childContainer);
folderElement.addEventListener("click", (e) => {
e.stopPropagation();
const arrow = folderElement.querySelector(".mxd-chooser-folder-arrow");
const contents = folderElement.nextElementSibling;
if (!contents) return;
if (contents.style.display === "none") {
contents.style.display = "block";
if (arrow) arrow.textContent = "v";
} else {
contents.style.display = "none";
if (arrow) arrow.textContent = ">";
}
});
}
};
const parent = rootItems[0]?.parentElement || items[0]?.parentElement || menuEl;
if (!parent) return;
const folderFragment = document.createDocumentFragment();
insertFolderStructure(folderFragment, folderMap);
parent.insertBefore(folderFragment, rootItems[0] || null);
}
// The built-in "Filter list" input (added by ComfyUI's own SearchFilter extension whenever
// a "dark" ContextMenu has >4 items) only does a literal lowercase substring match. We attach
// our own "input" listener *after* it so ours runs second and its display decision wins,
// re-scoring each item with token/typo-tolerant matching instead of requiring an exact substring.
function attachSmartChooserFilter(menuEl) {
if (!menuEl) return;
const filter = menuEl.querySelector(".comfy-context-menu-filter");
if (!filter || filter.dataset.mxdSmartFilter === "1") return;
filter.dataset.mxdSmartFilter = "1";
filter.addEventListener("input", () => {
const term = filter.value.trim();
if (!term) return;
const items = menuEl.querySelectorAll(".litemenu-entry");
for (const item of items) {
if (item.classList.contains("mxd-chooser-folder")) continue;
const candidate = item.getAttribute("data-value") || item.textContent || "";
item.style.display = isSmartMatch(term, candidate) ? "block" : "none";
}
});
}
function normalizeMenuCallbackValue(value) {
if (typeof value === "object" && value && "content" in value) {
return value.content;
}
return value;
}
/** Generic tree/search-enabled item chooser context menu, used for LoRAs, checkpoints, and
* the smart UNET/CLIP model lists. */
export async function showModelChooser(event, callback, parentMenu, items, title = "Choose an Item") {
const canvas = app.canvas;
ensureChooserTreeStyles();
const menu = new LiteGraph.ContextMenu(items, {
event,
parentMenu: parentMenu != null ? parentMenu : undefined,
title,
scale: Math.max(1, canvas.ds?.scale ?? 1),
className: "dark",
callback: (value, ...rest) => callback?.(normalizeMenuCallbackValue(value), ...rest),
});
requestAnimationFrame(() => {
const menuEl = getContextMenuRootElement(menu);
decorateChooserAsTree(menuEl);
attachSmartChooserFilter(menuEl);
});
}
export async function showLoraChooser(event, callback, parentMenu, loras) {
if (!loras) {
loras = await mxdApi.getLoras().then((items) => items.map((l) => l.file));
}
return showModelChooser(event, callback, parentMenu, loras, "Choose a LoRA");
}