- 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>
215 lines
6.9 KiB
JavaScript
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");
|
|
}
|