Files

422 lines
16 KiB
JavaScript

/**
* YFG Random Image From Directory — UI Extension v1.2.0
*
* Adds two native buttons to the RandomImageFromDirectory_node:
* 📁 Browse for Directory — navigable server-side directory browser modal
* 🕐 Recent Directories — MRU list from /yfg/dir_history
*
* v1.2.0: Inline output slot value display — shows index, dimensions,
* total count and previous index directly next to each output slot.
*
* @author Manny Gonzalez
* @title 🐯 YFG Comical Nodes
* @version 1.2.1
*/
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
const NODE_TYPE = "RandomImageFromDirectory_node";
// Output slot indices that get inline value display.
// Keys match what Python sends as flat ui dict keys.
const DISPLAY_SLOTS = {
yfg_index_current: 2,
yfg_width: 4,
yfg_height: 5,
yfg_total_count: 7,
yfg_index_previous: 9,
};
console.log("[YFG] RandomImageFromDirectory JS extension loading…");
// ─────────────────────────── API helpers ─────────────────────────────────────
async function apiGet(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function apiPost(path, body) {
const r = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
// ─────────────────────────── tiny DOM helpers ─────────────────────────────────
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function btn(label, cls, onclick) {
const b = el("button", cls, label);
b.type = "button";
b.onclick = onclick;
return b;
}
// ─────────────────────────── CSS (injected once) ──────────────────────────────
let _stylesInjected = false;
function ensureStyles() {
if (_stylesInjected) return;
_stylesInjected = true;
const css = `
/* ── overlay / modal ──────────────────────────────────────── */
.yfg-overlay {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,.65);
display: flex; align-items: center; justify-content: center;
}
.yfg-modal {
background: #1e1e2e; color: #cdd6f4;
border: 1px solid #45475a; border-radius: 10px;
width: min(680px, 93vw); max-height: 82vh;
display: flex; flex-direction: column;
font-family: ui-monospace, "Cascadia Code", monospace; font-size: 13px;
box-shadow: 0 12px 60px rgba(0,0,0,.85);
overflow: hidden;
}
.yfg-modal-header {
padding: 11px 16px; background: #181825;
border-bottom: 1px solid #45475a;
display: flex; align-items: center; gap: 8px;
}
.yfg-modal-title { font-weight: 700; font-size: 14px; color: #cba6f7; flex: 1; }
.yfg-breadcrumb {
padding: 7px 16px; background: #11111b;
border-bottom: 1px solid #313244;
font-size: 11px; color: #a6adc8;
word-break: break-all; min-height: 30px;
display: flex; align-items: center;
}
.yfg-dir-list {
flex: 1; overflow-y: auto; padding: 4px 0;
border-bottom: 1px solid #313244;
}
.yfg-dir-item {
padding: 8px 16px; cursor: pointer;
display: flex; align-items: center; gap: 9px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: background .1s; user-select: none;
}
.yfg-dir-item:hover { background: #313244; }
.yfg-dir-item.yfg-up { color: #89b4fa; font-style: italic; }
.yfg-dir-name { overflow: hidden; text-overflow: ellipsis; }
.yfg-modal-footer {
padding: 10px 16px; background: #181825;
display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid #45475a;
}
/* ── shared button styles ─────────────────────────────────── */
.yfg-btn {
padding: 6px 14px; border-radius: 6px; border: none;
cursor: pointer; font-size: 12px; font-weight: 700;
transition: opacity .15s, filter .15s;
}
.yfg-btn:hover { opacity: .85; }
.yfg-btn:active { filter: brightness(.85); }
.yfg-btn-primary { background: #cba6f7; color: #1e1e2e; }
.yfg-btn-secondary { background: #45475a; color: #cdd6f4; }
.yfg-btn-danger { background: #f38ba8; color: #1e1e2e; }
.yfg-btn-sm { padding: 3px 10px; font-size: 11px; }
/* ── states ───────────────────────────────────────────────── */
.yfg-empty { padding: 28px; text-align: center; color: #6c7086; white-space: pre-line; }
.yfg-spinner { text-align: center; padding: 22px; color: #89dceb; }
.yfg-err { padding: 14px 16px; color: #f38ba8; }
/* ── history list (inside modal) ──────────────────────────── */
.yfg-history-item {
padding: 9px 16px; cursor: pointer;
display: flex; align-items: center; gap: 9px;
border-bottom: 1px solid #313244;
transition: background .1s;
}
.yfg-history-item:hover { background: #313244; }
.yfg-history-item:last-of-type { border-bottom: none; }
.yfg-history-label {
flex: 1; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: #cdd6f4; font-size: 12px;
}
.yfg-history-del {
flex-shrink: 0; color: #f38ba8; cursor: pointer;
font-size: 15px; line-height: 1; padding: 0 3px;
opacity: .6; transition: opacity .1s;
}
.yfg-history-del:hover { opacity: 1; }
.yfg-history-clear-row {
padding: 8px 16px; display: flex; justify-content: flex-end;
border-top: 1px solid #45475a; background: #181825;
}
`;
const s = document.createElement("style");
s.textContent = css;
document.head.appendChild(s);
}
// ─────────────────────────── Directory Browser Modal ─────────────────────────
function openBrowseModal(startPath, onSelect) {
ensureStyles();
let activePath = (startPath || "").trim();
const overlay = el("div", "yfg-overlay");
const modal = el("div", "yfg-modal");
const header = el("div", "yfg-modal-header");
const title = el("span", "yfg-modal-title", "📁 Browse for Directory");
const breadcrumb = el("div", "yfg-breadcrumb", activePath || "Loading…");
const listWrap = el("div", "yfg-dir-list");
const footer = el("div", "yfg-modal-footer");
const cancelBtn = btn("Cancel", "yfg-btn yfg-btn-secondary", () => overlay.remove());
const selectBtn = btn("✅ Use This Directory", "yfg-btn yfg-btn-primary", () => {
if (activePath) { onSelect(activePath); overlay.remove(); }
});
selectBtn.disabled = !activePath;
header.append(title);
footer.append(cancelBtn, selectBtn);
modal.append(header, breadcrumb, listWrap, footer);
overlay.append(modal);
document.body.append(overlay);
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
async function navigate(path) {
listWrap.innerHTML = "";
listWrap.append(el("div", "yfg-spinner", "⏳ Loading…"));
try {
const url = path
? `/yfg/dir_browse?path=${encodeURIComponent(path)}`
: `/yfg/dir_browse`;
const data = await apiGet(url);
activePath = data.path || path || "";
breadcrumb.textContent = activePath || "Filesystem Roots";
selectBtn.disabled = !activePath;
listWrap.innerHTML = "";
if (data.parent !== null && data.parent !== undefined) {
const upRow = el("div", "yfg-dir-item yfg-up");
upRow.innerHTML = `<span>⬆️</span><span class="yfg-dir-name">.. up one level</span>`;
upRow.title = data.parent;
upRow.onclick = () => navigate(data.parent);
listWrap.append(upRow);
}
if (!data.dirs || data.dirs.length === 0) {
listWrap.append(el("div", "yfg-empty", "No subdirectories here."));
return;
}
for (const d of data.dirs) {
const row = el("div", "yfg-dir-item");
row.title = d.path;
row.innerHTML = `<span>📁</span><span class="yfg-dir-name"></span>`;
row.querySelector(".yfg-dir-name").textContent = d.name;
row.onclick = () => navigate(d.path);
listWrap.append(row);
}
} catch (e) {
listWrap.innerHTML = "";
listWrap.append(el("div", "yfg-err", `⚠️ ${e.message}`));
}
}
navigate(activePath || "");
}
// ─────────────────────────── History Modal ───────────────────────────────────
function openHistoryModal(onSelect) {
ensureStyles();
const overlay = el("div", "yfg-overlay");
const modal = el("div", "yfg-modal");
const header = el("div", "yfg-modal-header");
const title = el("span", "yfg-modal-title", "🕐 Recent Directories");
const listWrap = el("div", "yfg-dir-list");
const footer = el("div", "yfg-modal-footer");
const closeBtn = btn("Close", "yfg-btn yfg-btn-secondary", () => overlay.remove());
header.append(title);
footer.append(closeBtn);
modal.append(header, listWrap, footer);
overlay.append(modal);
document.body.append(overlay);
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
async function render() {
listWrap.innerHTML = "";
listWrap.append(el("div", "yfg-spinner", "⏳ Loading…"));
try {
const history = await apiGet("/yfg/dir_history");
listWrap.innerHTML = "";
if (!history || history.length === 0) {
listWrap.append(el("div", "yfg-empty",
"No history yet.\n\nDirectories are saved automatically\neach time the node runs."));
return;
}
for (const dir of history) {
const row = el("div", "yfg-history-item");
const label = el("span", "yfg-history-label", dir);
const del = el("span", "yfg-history-del", "✕");
label.title = dir;
label.onclick = () => { onSelect(dir); overlay.remove(); };
del.title = "Remove from history";
del.onclick = async ev => {
ev.stopPropagation();
await apiPost("/yfg/dir_history/remove", { directory: dir });
render();
};
row.append(label, del);
listWrap.append(row);
}
const clearRow = el("div", "yfg-history-clear-row");
const clearBtn = btn("🗑 Clear All History", "yfg-btn yfg-btn-danger yfg-btn-sm",
async () => {
await apiPost("/yfg/dir_history/clear", {});
render();
});
clearRow.append(clearBtn);
listWrap.append(clearRow);
} catch (e) {
listWrap.innerHTML = "";
listWrap.append(el("div", "yfg-err", `⚠️ ${e.message}`));
}
}
render();
}
// ─────────────────────────── Output slot value display ───────────────────────
// Update the display-enabled output slots with their current values.
// Uses slot.label (display-only, not reset by ComfyUI) instead of slot.name.
// Wrapped in setTimeout to run after ComfyUI's post-execution node refresh.
function applyOutputValues(node, output) {
setTimeout(() => {
for (const [key, slotIdx] of Object.entries(DISPLAY_SLOTS)) {
const raw = output?.[key];
if (raw === undefined || raw === null) continue;
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === undefined) continue;
const slot = node.outputs?.[slotIdx];
if (!slot) continue;
// origName: prefer stored name, else current label/name minus any prefix
let origName = node._yfgOrigOutputNames?.[slotIdx];
if (!origName) {
const src = slot.label || slot.name || "";
const parts = src.split(" ");
origName = parts.length > 1 ? parts.slice(1).join(" ") : src;
}
const displayVal = (key === "yfg_index_previous" && value === -1) ? "-" : String(value);
// Set label (display override) — ComfyUI doesn't reset this
slot.label = `${displayVal} ${origName}`;
// For index_current: also copy value into the INDEX input widget
// so switching to by_index needs no manual typing.
if (key === "yfg_index_current") {
const idxWidget = node.widgets?.find(w => w.name === "index");
if (idxWidget) idxWidget.value = value;
}
}
// Force full canvas redraw
if (app.canvas?.draw) {
app.canvas.draw(true, true);
} else {
node.setDirtyCanvas(true, true);
}
}, 100);
}
// ─────────────────────────── ComfyUI Extension ───────────────────────────────
app.registerExtension({
name: "YFG.RandomImageFromDirectory",
// ── Listen for execution results and update output slot labels ────────────
async setup() {
api.addEventListener("executed", ({ detail }) => {
const nodeId = parseInt(detail.node ?? detail.nodeId);
if (isNaN(nodeId)) return;
const node = app.graph.getNodeById(nodeId);
if (!node) return;
if (node.type !== NODE_TYPE && node.comfyClass !== NODE_TYPE) return;
const output = detail?.output;
if (!output) return;
applyOutputValues(node, output);
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== NODE_TYPE) return;
console.log("[YFG] Hooking RandomImageFromDirectory_node…");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const self = this;
// ── Store original output names (once) for value prepending ───────
self._yfgOrigOutputNames = (self.outputs ?? []).map(o => o.name);
// ── Browse / Recent buttons ───────────────────────────────────────
const dirWidget = self.widgets?.find(w => w.name === "image_directory");
if (!dirWidget) {
console.warn("[YFG] image_directory widget not found on node.");
return result;
}
function applyPath(selectedPath) {
dirWidget.value = selectedPath;
if (typeof dirWidget.callback === "function") {
dirWidget.callback(selectedPath);
}
app.graph.setDirtyCanvas(true, true);
}
self.addWidget(
"button",
"📁 Browse for Directory",
"browse",
() => openBrowseModal(dirWidget.value || "", applyPath)
);
self.addWidget(
"button",
"🕐 Recent Directories",
"recent",
() => openHistoryModal(applyPath)
);
console.log("[YFG] ✅ Browse + Recent buttons added to node.");
return result;
};
},
});