|
|
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"; |
|
|
const IMAGE_WIDTH = 384; |
|
|
const IMAGE_HEIGHT = 384; |
|
|
|
|
|
function getType(node) { |
|
|
if (node.comfyClass === CHECKPOINT_LOADER) { |
|
|
return "checkpoints"; |
|
|
} |
|
|
return "loras"; |
|
|
} |
|
|
|
|
|
function getWidgetName(type) { |
|
|
return type === "checkpoints" ? "ckpt_name" : "lora_name"; |
|
|
} |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
let imagesByType = {}; |
|
|
const loadImageList = async (type) => { |
|
|
imagesByType[type] = await (await api.fetchApi(`/pysssss/images/${type}`)).json(); |
|
|
}; |
|
|
|
|
|
app.registerExtension({ |
|
|
name: "pysssss.Combo++", |
|
|
init() { |
|
|
const displayOptions = { "List (normal)": 0, "Tree (subfolders)": 1, "Thumbnails (grid)": 2 }; |
|
|
const displaySetting = app.ui.settings.addSetting({ |
|
|
id: "pysssss.Combo++.Submenu", |
|
|
name: "🐍 Lora & Checkpoint loader display mode", |
|
|
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 p1 = loadImageList("checkpoints"); |
|
|
const p2 = loadImageList("loras"); |
|
|
|
|
|
const refreshComboInNodes = app.refreshComboInNodes; |
|
|
app.refreshComboInNodes = async function () { |
|
|
const r = await Promise.all([ |
|
|
refreshComboInNodes.apply(this, arguments), |
|
|
loadImageList("checkpoints").catch(() => {}), |
|
|
loadImageList("loras").catch(() => {}), |
|
|
]); |
|
|
return r[0]; |
|
|
}; |
|
|
|
|
|
const imageHost = $el("img.pysssss-combo-image"); |
|
|
|
|
|
const positionMenu = (menu, fillWidth) => { |
|
|
|
|
|
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, type) => { |
|
|
try { |
|
|
await p1; |
|
|
await p2; |
|
|
} catch (error) { |
|
|
console.error(error); |
|
|
console.error("Error loading pysssss.betterCombos data"); |
|
|
} |
|
|
|
|
|
|
|
|
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"); |
|
|
|
|
|
|
|
|
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 = () => { |
|
|
|
|
|
const folderMap = new Map(); |
|
|
const rootItems = []; |
|
|
const splitBy = (navigator.platform || navigator.userAgent).includes("Win") ? /\/|\\/ : /\//; |
|
|
const itemsSymbol = Symbol("items"); |
|
|
|
|
|
|
|
|
for (const item of items) { |
|
|
const path = item.getAttribute("data-value").split(splitBy); |
|
|
|
|
|
|
|
|
item.textContent = path[path.length - 1]; |
|
|
if (path.length > 1) { |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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 = $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" }, |
|
|
}); |
|
|
|
|
|
|
|
|
const items = content.get(itemsSymbol) || []; |
|
|
for (const item of items) { |
|
|
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(".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 = "▶"; |
|
|
} |
|
|
}); |
|
|
} |
|
|
}; |
|
|
|
|
|
insertFolderStructure(items[0]?.parentElement || menu, folderMap); |
|
|
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; |
|
|
|
|
|
if (!node || (node.comfyClass !== LORA_LOADER && node.comfyClass !== CHECKPOINT_LOADER)) { |
|
|
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(); |
|
|
const type = getType(node); |
|
|
if (overWidget?.name === getWidgetName(type)) { |
|
|
requestAnimationFrame(() => { |
|
|
|
|
|
if (!added.querySelector(".comfy-context-menu-filter")) return; |
|
|
updateMenu(added, type); |
|
|
}); |
|
|
} |
|
|
return; |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
mutationObserver.observe(document.body, { childList: true, subtree: false }); |
|
|
}, |
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) { |
|
|
const isCkpt = nodeData.name === CHECKPOINT_LOADER; |
|
|
const isLora = nodeData.name === 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); |
|
|
this.widgets.find((w) => w.name === "prompt").computeSize = () => [0, -4]; |
|
|
let exampleWidget; |
|
|
|
|
|
const get = async (route, suffix) => { |
|
|
const url = encodeRFC3986URIComponent(`${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; |
|
|
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) { |
|
|
try { |
|
|
examples = await (await get("examples", `/${this.widgets[0].value}`)).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); |
|
|
}; |
|
|
|
|
|
|
|
|
nodeType.prototype["pysssss.updateExamples"] = listExamples; |
|
|
|
|
|
const modelWidget = this.widgets[0]; |
|
|
const modelCb = modelWidget.callback; |
|
|
let prev = undefined; |
|
|
modelWidget.callback = function () { |
|
|
let ret = modelCb?.apply(this, arguments) ?? modelWidget.value; |
|
|
if (typeof ret === "object" && "content" in ret) { |
|
|
ret = ret.content; |
|
|
modelWidget.value = ret; |
|
|
} |
|
|
let v = ret; |
|
|
if (prev !== v) { |
|
|
listExamples(); |
|
|
prev = v; |
|
|
} |
|
|
return ret; |
|
|
}; |
|
|
setTimeout(() => { |
|
|
modelWidget.callback(); |
|
|
}, 30); |
|
|
}; |
|
|
} |
|
|
|
|
|
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; |
|
|
nodeType.prototype.getExtraMenuOptions = function (_, options) { |
|
|
if (this.imgs) { |
|
|
|
|
|
let img; |
|
|
if (this.imageIndex != null) { |
|
|
|
|
|
img = this.imgs[this.imageIndex]; |
|
|
} else if (this.overIndex != null) { |
|
|
|
|
|
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, |
|
|
callback: async () => { |
|
|
const url = new URL(img.src); |
|
|
await api.fetchApi("/pysssss/save/" + encodeRFC3986URIComponent(`${getType(n)}/${n.widgets[0].value}`), { |
|
|
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", |
|
|
}, |
|
|
}); |
|
|
loadImageList(getType(n)); |
|
|
}, |
|
|
})), |
|
|
}, |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
return getExtraMenuOptions?.apply(this, arguments); |
|
|
}; |
|
|
}, |
|
|
}); |
|
|
|