Files

1067 lines
48 KiB
JavaScript

import { app } from "/scripts/app.js";
import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js";
import { store } from "../core/store.js";
import { t } from "../core/i18n.js";
import { getOrCreateCard, updateCardDOM, resetAllCardHoverStates, cardPool } from "./cardRenderer.js";
import { clearCancelledOrFailed, clearAllHistory, cancelAllPending } from "../queue/queueService.js";
import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { matchesFilter, getRunOutputs, isImageFormat, animateCardRemoval, getScrollContainer, calculateGridColumns, setupSpotlightTracker, attachDeleteConfirmation } from "../utils/utils.js";
import { getSetting } from "../core/settings.js";
import { renderCardImages } from "./mediaPreview.js";
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
import { toggleIgnoreActiveNode } from "../comfy/nodes.js";
import { renumberNodesTopologically, showNotification } from "../utils/nodeRenumber.js";
let scrollToTopBtnEl = null;
let globalClickRegistered = false;
let renderAnimationFrameId = null;
let activeTimerInterval = null;
let currentColumnCount = 1;
let stickyHeaderGroupEl = null;
let lastScrollTop = 0;
let scrollDeltaAccumulator = 0;
let isHeaderHidden = false;
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
if (!cardStack) return;
// 1. Full-width container (for banners like Pending Queue summary)
let bannerContainer = cardStack.querySelector('.comfy-sidebar-banners');
if (!bannerContainer) {
bannerContainer = document.createElement('div');
bannerContainer.className = 'comfy-sidebar-banners';
Object.assign(bannerContainer.style, { display: 'flex', flexDirection: 'column', gap: '12px', width: '100%' });
cardStack.insertBefore(bannerContainer, cardStack.firstChild);
}
fullWidthEls.forEach((el, i) => {
if (bannerContainer.children[i] !== el) {
bannerContainer.insertBefore(el, bannerContainer.children[i] || null);
}
});
while (bannerContainer.children.length > fullWidthEls.length) {
bannerContainer.removeChild(bannerContainer.lastChild);
}
bannerContainer.style.display = fullWidthEls.length > 0 ? 'flex' : 'none';
bannerContainer.style.marginBottom = fullWidthEls.length > 0 ? '12px' : '0';
// 2. Masonry Columns container
let columnsWrapper = cardStack.querySelector('.comfy-sidebar-masonry-wrapper');
if (!columnsWrapper) {
columnsWrapper = document.createElement('div');
columnsWrapper.className = 'comfy-sidebar-masonry-wrapper';
Object.assign(columnsWrapper.style, { display: 'flex', gap: '12px', alignItems: 'flex-start', width: '100%' });
cardStack.appendChild(columnsWrapper);
}
while (columnsWrapper.children.length < cols) {
const colDiv = document.createElement('div');
colDiv.className = 'comfy-sidebar-masonry-col';
Object.assign(colDiv.style, { flex: '1', display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '0' });
columnsWrapper.appendChild(colDiv);
}
while (columnsWrapper.children.length > cols) {
columnsWrapper.removeChild(columnsWrapper.lastChild);
}
// Distribute cards into the shortest column so short cards stack next to tall cards
const colBuckets = Array.from({ length: cols }, () => []);
const colHeights = new Array(cols).fill(0);
cardEls.forEach((cardEl) => {
let shortestCol = 0;
for (let c = 1; c < cols; c++) {
if (colHeights[c] < colHeights[shortestCol]) {
shortestCol = c;
}
}
colBuckets[shortestCol].push(cardEl);
const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : (cardEl._cachedHeight || 160);
cardEl._cachedHeight = cardH;
colHeights[shortestCol] += cardH + 12;
});
for (let c = 0; c < cols; c++) {
const colDiv = columnsWrapper.children[c];
const bucket = colBuckets[c];
bucket.forEach((el, i) => {
if (colDiv.children[i] !== el) {
colDiv.insertBefore(el, colDiv.children[i] || null);
}
});
while (colDiv.children.length > bucket.length) {
colDiv.removeChild(colDiv.lastChild);
}
}
}
function handleQueueHeaderScroll(scrollEl) {
if (!stickyHeaderGroupEl) return;
const currentScroll = scrollEl.scrollTop;
const searchContainer = stickyHeaderGroupEl.querySelector(".comfy-sidebar-search-container");
const searchIsOpen = searchContainer && searchContainer.style.display !== "none";
// Keep the frosted surface visible while search is open, matching Gallery.
stickyHeaderGroupEl.classList.toggle("is-scrolled", currentScroll > 35);
if (searchIsOpen) {
lastScrollTop = currentScroll;
scrollDeltaAccumulator = 0;
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
return;
}
const delta = currentScroll - lastScrollTop;
lastScrollTop = currentScroll;
if (currentScroll <= 35) {
stickyHeaderGroupEl.classList.remove("is-scrolled");
if (isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
}
scrollDeltaAccumulator = 0;
return;
}
stickyHeaderGroupEl.classList.add("is-scrolled");
if (delta > 0) {
if (scrollDeltaAccumulator < 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator > 25 && !isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(-100%)";
isHeaderHidden = true;
}
} else if (delta < 0) {
if (scrollDeltaAccumulator > 0) scrollDeltaAccumulator = 0;
scrollDeltaAccumulator += delta;
if (scrollDeltaAccumulator < -15 && isHeaderHidden) {
stickyHeaderGroupEl.style.transform = "translateY(0)";
isHeaderHidden = false;
}
}
}
function updateScrollTopBtnVisibility() {
if (!scrollToTopBtnEl) return;
if (!store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) {
scrollToTopBtnEl.style.display = "none";
return;
}
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) {
if (window.getComputedStyle(viewportEl).position === "static") {
viewportEl.style.position = "relative";
}
viewportEl.appendChild(scrollToTopBtnEl);
}
if (scrollEl.scrollTop > 200) {
scrollToTopBtnEl.style.display = "flex";
} else {
scrollToTopBtnEl.style.display = "none";
}
handleQueueHeaderScroll(scrollEl);
}
}
const handleGlobalKeyDown = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.add("comfy-sidebar-ctrl-active");
}
};
const handleGlobalKeyUp = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.remove("comfy-sidebar-ctrl-active");
}
};
const handleGlobalBlur = () => {
document.body.classList.remove("comfy-sidebar-ctrl-active");
};
const handleGlobalClick = (e) => {
if (!store.ui.activeSubmenuPromptId && !store.ui.activeSubmenuBatchImages) {
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
return;
}
const sidebar = store.ui.sidebarContainer;
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay');
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
store.closeSubmenu();
resetAllCardHoverStates();
renderSidebar();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
}
};
export function setupSidebarView() {
window.addEventListener("keydown", handleGlobalKeyDown);
window.addEventListener("keyup", handleGlobalKeyUp);
window.addEventListener("blur", handleGlobalBlur);
const container = document.createElement("div");
store.ui.sidebarContainer = container;
Object.assign(container.style, {
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
background: "transparent",
color: "var(--fg-color, var(--p-text-color, #eee))", position: "relative"
});
const header = document.createElement("div");
header.className = "comfy-sidebar-header-root";
Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" });
const standardHeader = document.createElement("div");
standardHeader.className = "comfy-sidebar-standard-header";
Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
const titleGroup = document.createElement("div");
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
searchIcon.title = t("Search History");
const title = document.createElement("h3");
title.textContent = t("Queue");
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
titleGroup.appendChild(searchIcon);
titleGroup.appendChild(title);
standardHeader.appendChild(titleGroup);
const actionsGroup = document.createElement("div");
Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" });
const createInstantBtn = (iconClass, tooltip, onClickFn) => {
const btn = document.createElement("button");
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
btn.addEventListener("click", async (ev) => {
ev.stopPropagation();
btn.style.transform = "scale(0.88)";
setTimeout(() => { btn.style.transform = ""; }, 120);
await onClickFn();
});
return btn;
};
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
const btn = document.createElement("button");
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
let timeout = null, isPending = false;
const reset = () => {
isPending = false;
Object.assign(btn.style, {
color: "", background: "",
borderColor: "", boxShadow: ""
});
if (timeout) { clearTimeout(timeout); timeout = null; }
};
btn.addEventListener("click", async (ev) => {
ev.stopPropagation();
if (!isPending) {
isPending = true;
Object.assign(btn.style, {
color: "#fff", background: hoverColor, borderColor: hoverColor,
boxShadow: `0 0 8px ${hoverColor}80`
});
timeout = setTimeout(reset, 1500);
} else {
reset();
await onClickFn();
}
});
return btn;
};
const btnToggleIgnore = createInstantBtn("pi pi-eye-slash", t("Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)"), () => {
const raw = app.canvas?.selected_nodes;
const selected = raw instanceof Map || raw instanceof Set
? Array.from(raw.values())
: (Array.isArray(raw) ? raw : (raw && typeof raw === "object" ? Object.values(raw) : []));
const selectedNodes = selected.length > 0 ? selected : (app.canvas?.selected_node ? [app.canvas.selected_node] : []);
if (selectedNodes.length === 0) {
showNotification(t("No nodes selected. Click a node on the canvas first to toggle ignore."), "warn");
return;
}
toggleIgnoreActiveNode(() => renderSidebar());
});
const btnRenumber = createInstantBtn("pi pi-sort-numeric-down", t("Renumber All Nodes by Execution Order (Alt+R)"), async () => {
await renumberNodesTopologically();
});
const divider = document.createElement("div");
Object.assign(divider.style, {
width: "1px", height: "14px", background: "var(--border, var(--border-color, #444))",
margin: "0 2px", opacity: "0.6"
});
const btnClearInterrupted = createActionBtn("pi pi-eraser", t("Clear Cancelled & Failed"), "#ffc107", async () => {
await clearCancelledOrFailed();
renderSidebar();
});
const btnClearAll = createActionBtn("pi pi-trash", t("Clear All History"), "#dc3545", async () => {
await clearAllHistory();
renderSidebar();
});
actionsGroup.appendChild(btnToggleIgnore);
actionsGroup.appendChild(btnRenumber);
actionsGroup.appendChild(divider);
actionsGroup.appendChild(btnClearInterrupted);
actionsGroup.appendChild(btnClearAll);
standardHeader.appendChild(actionsGroup);
const searchContainer = document.createElement("div");
searchContainer.className = "comfy-sidebar-search-container";
Object.assign(searchContainer.style, {
display: "none", width: "100%", alignItems: "center",
background: "var(--card, var(--secondary, var(--comfy-input-bg, #181818)))",
border: "1px solid var(--border, var(--border-color, #555))", borderRadius: "4px", padding: "2px 8px",
boxSizing: "border-box", height: "26px"
});
const searchInputIcon = document.createElement("span");
searchInputIcon.className = "pi pi-search";
Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" });
const searchInput = document.createElement("input");
Object.assign(searchInput, { type: "text", placeholder: t("Filter by text, images, nodes...") });
Object.assign(searchInput.style, {
flex: "1", background: "transparent", border: "none", outline: "none",
color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
});
const clearSearchBtn = document.createElement("span");
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
clearSearchBtn.title = t("Clear & Close Search");
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
searchContainer.appendChild(searchInputIcon);
searchContainer.appendChild(searchInput);
searchContainer.appendChild(clearSearchBtn);
header.appendChild(standardHeader);
header.appendChild(searchContainer);
const stickyHeaderGroup = document.createElement("div");
stickyHeaderGroup.className = "comfy-sidebar-sticky-header-group";
stickyHeaderGroupEl = stickyHeaderGroup;
stickyHeaderGroup.appendChild(header);
container.appendChild(stickyHeaderGroup);
searchIcon.addEventListener("click", (e) => {
e.stopPropagation();
standardHeader.style.display = "none";
searchContainer.style.display = "flex";
searchInput.focus();
});
const closeSearch = () => {
searchInput.value = "";
store.setSearchQuery("");
searchContainer.style.display = "none";
standardHeader.style.display = "flex";
renderSidebar();
};
clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); });
searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); });
searchInput.addEventListener("input", () => {
store.setSearchQuery(searchInput.value.trim());
renderSidebar();
});
const cardStack = document.createElement("div");
store.ui.cardStack = cardStack;
Object.assign(cardStack.style, {
flex: "1",
overflowY: "visible",
display: "flex",
flexDirection: "column",
paddingBottom: "46px"
});
container.appendChild(cardStack);
scrollToTopBtnEl = document.createElement("button");
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
scrollToTopBtnEl.title = t("Scroll to Top");
scrollToTopBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
});
container.appendChild(scrollToTopBtnEl);
// Reveal Spotlight: track cursor position over Queue card borders
setupSpotlightTracker(container, ".comfy-sidebar-card");
document.addEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
new ResizeObserver((entries) => {
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const cols = calculateGridColumns(entries[0].contentRect.width, threshold);
if (cols !== currentColumnCount) {
currentColumnCount = cols;
renderSidebar();
}
}).observe(container);
activeTimerInterval = setInterval(() => {
const activePid = store.ui.currentlyActivePromptId;
if (!activePid) return;
const prompt = store.getPrompt(activePid);
if (prompt && prompt.status === PromptStatus.ACTIVE && prompt.startTime) {
const cardObj = cardPool.get(activePid);
if (cardObj && cardObj.timerEl) {
cardObj.timerEl.textContent = ((Date.now() - prompt.startTime) / 1000).toFixed(2) + "s";
}
}
}, 250);
store.on(StoreEvents.QUEUE_SYNCED, () => {
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
const runningCount = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE).length;
updateSidebarBadge(pendingCount + (runningCount > 0 ? 1 : 0));
renderSidebar();
});
store.on(StoreEvents.PROMPT_ADDED, () => renderSidebar());
store.on(StoreEvents.PROMPT_DELETED, () => renderSidebar());
store.on(StoreEvents.PROMPTS_CLEARED, () => renderSidebar());
store.on(StoreEvents.SUBMENU_CHANGED, () => renderSidebar());
return container;
}
export function renderSidebar() {
if (renderAnimationFrameId) cancelAnimationFrame(renderAnimationFrameId);
renderAnimationFrameId = requestAnimationFrame(() => {
if (!store.ui.sidebarContainer || !store.ui.cardStack) return;
const showPendingSummary = getSetting(SettingIds.SHOW_PENDING_COUNT_ONLY, true);
const showWorkingNode = getSetting(SettingIds.SHOW_WORKING_NODE_NAME, true);
const headerTitle = store.ui.sidebarContainer.querySelector("h3");
const headerSearchIcon = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-root .pi-search");
const headerActions = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-btn")?.parentNode;
// Submenu 1: Batch Images View
if (store.ui.activeSubmenuBatchImages) {
const batchInfo = store.ui.activeSubmenuBatchImages;
if (headerTitle) {
headerTitle.textContent = `${t("Batch of #")}${batchInfo.pid}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = t("Go Back");
headerTitle.onclick = () => {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
}
resetAllCardHoverStates();
renderSidebar();
};
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
if (!globalClickRegistered) {
document.addEventListener("click", handleGlobalClick, true);
globalClickRegistered = true;
}
const targetElements = [];
batchInfo.images.forEach((img, index) => {
const cardId = `batch-${batchInfo.pid}-${index}`;
let cardObj = cardPool.get(cardId);
if (!cardObj) {
const card = document.createElement("div");
card.id = `card-${cardId}`;
card.className = "comfy-sidebar-card completed";
card.style.position = "relative";
const timerEl = document.createElement("div");
timerEl.className = "comfy-sidebar-card-timer";
timerEl.textContent = `Image ${index + 1}/${batchInfo.images.length}`;
timerEl.style.display = "block";
const dimEl = document.createElement("div");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = t("Download Object");
btnImg.onclick = (ev) => {
ev.stopPropagation();
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
a.download = img.filename || "output";
a.click();
};
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = t("Delete File");
btnDel.appendChild(btnDelLabel);
attachDeleteConfirmation(btnDel, {
idleTitle: t("Remove from batch (Hold Ctrl to delete file from disk)"),
confirmTitle: t("Click again to confirm removing from batch"),
confirmDiskTitle: t("Ctrl+Click again to delete file from DISK / TRASH"),
onConfirm: async (shouldDeleteFromDisk) => {
animateCardRemoval(card, async () => {
if (shouldDeleteFromDisk && img && img.filename) {
const action = await deleteFileOnServer(img);
if (action) {
const msg = action === "trashed" ? t("Moved file to Recycle Bin.") : t("Permanently deleted file.");
showNotification(msg, "success", t("Delete File"));
}
}
const pid = batchInfo.parentPromptId || batchInfo.pid;
const state = store.getPrompt(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
}
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
}
state.rendered = false;
store.updatePrompt(pid, state);
}
const mainCardObj = cardPool.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
if (batchInfo.images.length === 0) {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
}
}
renderSidebar();
});
}
});
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = t("Copy to Clipboard");
const isCopyable = isImageFormat(img.filename || img.url);
if (!isCopyable) {
btnCopy.style.display = "none";
} else {
btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const success = await copyImageToClipboard(src);
if (success) {
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
btnCopy.style.color = "#4ade80";
setTimeout(() => {
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.style.color = "";
}, 1500);
}
};
}
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = t("Show Node");
const nodeId = findNodeIdForImage({ nodeOutputs: batchInfo.nodeOutputs }, img);
if (nodeId) {
btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(nodeId);
};
} else {
btnFocus.style.display = "none";
}
leftHoverPanel.append(btnCopy, btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
timerEl,
dimEl,
grid,
placeholder: p,
hoverPanel,
leftHoverPanel,
btnImg,
btnCopy,
btnFocus,
btnDel,
firstImgElement: null
};
cardPool.set(cardId, cardObj);
}
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, {
pid: batchInfo.pid,
status: PromptStatus.COMPLETED,
images: [img],
workflow: batchInfo.workflow,
nodeOutputs: batchInfo.nodeOutputs
});
targetElements.push(cardObj.element);
});
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) scrollEl.scrollTop = 0;
updateScrollTopBtnVisibility();
return;
}
// Submenu 2: Intermediate Outputs View
if (store.ui.activeSubmenuPromptId) {
const st = store.getPrompt(store.ui.activeSubmenuPromptId);
if (!st) {
store.closeSubmenu();
renderSidebar();
return;
}
if (headerTitle) {
headerTitle.textContent = `${t("Outputs of #")}${store.ui.activeSubmenuPromptId}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = t("Go Back to Queue");
headerTitle.onclick = () => {
store.closeSubmenu();
resetAllCardHoverStates();
renderSidebar();
};
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
if (!globalClickRegistered) {
document.addEventListener("click", handleGlobalClick, true);
globalClickRegistered = true;
}
const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
const seenFiles = new Set();
const outputs = [];
rawOutputs.forEach(out => {
const uniqueImgs = (out.images || []).filter(img => {
const key = `${img.subfolder || ""}/${img.filename}`;
if (seenFiles.has(key)) return false;
seenFiles.add(key);
return true;
});
if (uniqueImgs.length > 0) {
outputs.push({ nodeId: out.nodeId, images: uniqueImgs, nodeTitle: out.nodeTitle });
}
});
const targetElements = [];
if (outputs.length === 0) {
const emptyCard = document.createElement("div");
emptyCard.className = "comfy-sidebar-card completed";
Object.assign(emptyCard.style, {
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px",
gridColumn: "1 / -1"
});
emptyCard.textContent = t("No separate intermediate outputs found.");
targetElements.push(emptyCard);
} else {
outputs.forEach((out) => {
const cardId = `submenu-${st.pid}-${out.nodeId}`;
let cardObj = cardPool.get(cardId);
if (!cardObj) {
const card = document.createElement("div");
card.id = `card-${cardId}`;
card.className = "comfy-sidebar-card completed";
card.style.position = "relative";
const titleFromState = st.nodeTitles?.[out.nodeId];
const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId));
const resolvedTitle = titleFromState || (node ? (node.title || node.type) : out.nodeTitle) || "Node";
const timerEl = document.createElement("div");
timerEl.className = "comfy-sidebar-card-timer";
timerEl.textContent = `${resolvedTitle} (#${out.nodeId})`;
timerEl.style.display = "block";
const dimEl = document.createElement("div");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = t("Download Object");
btnImg.onclick = (ev) => {
ev.stopPropagation();
out.images.forEach(img => {
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
a.download = img.filename || "output";
a.click();
});
};
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = t("Delete File");
btnDel.appendChild(btnDelLabel);
attachDeleteConfirmation(btnDel, {
idleTitle: t("Delete node output (Hold Ctrl to delete file from disk)"),
confirmTitle: t("Click again to confirm removing output"),
confirmDiskTitle: t("Ctrl+Click again to delete this node's files from DISK / TRASH"),
onConfirm: async (shouldDeleteFromDisk) => {
animateCardRemoval(card, async () => {
if (shouldDeleteFromDisk && out.images) {
let count = 0;
let lastAct = "trashed";
for (const imgItem of out.images) {
const action = await deleteFileOnServer(imgItem);
if (action) { count++; lastAct = action; }
}
if (count > 0) {
const msg = lastAct === "trashed"
? `Moved ${count} file${count > 1 ? "s" : ""} to Recycle Bin.`
: `Permanently deleted ${count} file${count > 1 ? "s" : ""}.`;
showNotification(msg, "success", "Delete Files");
}
}
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
store.closeSubmenu();
}
store.updatePrompt(st.pid, st);
renderSidebar();
});
}
});
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = t("Copy to Clipboard");
const firstImg = out.images[0];
const isCopyable = firstImg && isImageFormat(firstImg.filename || firstImg.url);
if (!isCopyable) {
btnCopy.style.display = "none";
} else {
btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = firstImg.url ? firstImg.url : `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}`;
const success = await copyImageToClipboard(src);
if (success) {
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
btnCopy.style.color = "#4ade80";
setTimeout(() => {
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.style.color = "";
}, 1500);
}
};
}
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = t("Show Node");
btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(out.nodeId);
};
leftHoverPanel.append(btnCopy, btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
timerEl,
dimEl,
grid,
placeholder: p,
hoverPanel,
leftHoverPanel,
btnImg,
btnCopy,
btnFocus,
btnDel,
firstImgElement: null
};
cardPool.set(cardId, cardObj);
}
if (out.images && out.images.length > 0) {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, {
pid: st.pid,
nodeId: out.nodeId,
status: PromptStatus.COMPLETED,
images: out.images,
workflow: st.workflow,
nodeOutputs: st.nodeOutputs
}, () => {
// Clicking (1/N) opens batch drill-down for this specific intermediate node
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
const nodeTitle = st.nodeTitles?.[out.nodeId] || out.nodeTitle || "Node";
store.openBatchSubmenu({
pid: `${st.pid} - ${nodeTitle} (#${out.nodeId})`,
parentPromptId: st.pid,
images: out.images,
workflow: st.workflow,
nodeOutputs: { [out.nodeId]: st.nodeOutputs?.[out.nodeId] }
});
});
} else {
cardObj.placeholder.style.display = "block";
cardObj.placeholder.textContent = t("No Outputs");
}
targetElements.push(cardObj.element);
});
}
if (outputs.length === 0) {
mountCards(store.ui.cardStack, targetElements, [], currentColumnCount);
} else {
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
}
updateScrollTopBtnVisibility();
return;
}
// Main Queue View
if (headerTitle) {
headerTitle.textContent = t("Queue");
headerTitle.style.cursor = "default";
headerTitle.onclick = null;
}
if (headerSearchIcon) headerSearchIcon.style.display = "inline";
if (headerActions) headerActions.style.display = "flex";
let tasksArray = store.getAllPrompts();
if (showPendingSummary) {
tasksArray = tasksArray.filter(t => t.status !== PromptStatus.PENDING);
}
if (store.ui.searchQuery) {
tasksArray = tasksArray.filter(t => matchesFilter(t, store.ui.searchQuery));
}
tasksArray.sort((a, b) => b.timestamp - a.timestamp);
const activeTasks = tasksArray.filter(t => t.status === PromptStatus.ACTIVE);
const completedTasks = tasksArray.filter(t => t.status === PromptStatus.COMPLETED || t.status === PromptStatus.CANCELLED || t.status === PromptStatus.ERROR);
const pendingTasks = tasksArray.filter(t => t.status === PromptStatus.PENDING).sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0));
const syncTaskCard = (state) => {
const cardObj = getOrCreateCard(state);
return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode);
};
const bannerElements = [];
const cardElements = [];
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
if (pendingCount > 0) {
if (showPendingSummary) {
let pCard = cardPool.get("pending-summary-card");
if (!pCard) {
const el = document.createElement("div");
Object.assign(el.style, {
background: "var(--card, var(--secondary, var(--comfy-input-bg, #181818)))", border: "2px solid var(--border, #6c757d)", borderRadius: "4px", padding: "10px",
textAlign: "center", fontSize: "12px", fontWeight: "bold",
color: "var(--muted-foreground, var(--desc-color, #aaa))", display: "flex", flexDirection: "column", gap: "8px"
});
const textDiv = document.createElement("div");
el.appendChild(textDiv);
const cancelBtn = document.createElement("button");
cancelBtn.className = "comfy-sidebar-cancel-all-btn";
cancelBtn.textContent = t("Cancel All Pending");
cancelBtn.onclick = async () => {
await cancelAllPending();
};
el.appendChild(cancelBtn);
pCard = { element: el, textDiv };
cardPool.set("pending-summary-card", pCard);
}
pCard.textDiv.textContent = `${t("Pending Queue:")} ${pendingCount} ${t("tasks")}`;
bannerElements.push(pCard.element);
} else {
let btnCard = cardPool.get("pending-cancel-all-standalone");
if (!btnCard) {
const btn = document.createElement("button");
btn.className = "comfy-sidebar-cancel-all-btn";
btn.textContent = t("Cancel All Pending");
btn.onclick = async () => {
await cancelAllPending();
};
btnCard = { element: btn };
cardPool.set("pending-cancel-all-standalone", btnCard);
}
bannerElements.push(btnCard.element);
pendingTasks.forEach(st => cardElements.push(syncTaskCard(st)));
}
}
activeTasks.forEach(st => cardElements.push(syncTaskCard(st)));
completedTasks.forEach(st => cardElements.push(syncTaskCard(st)));
const threshold = getSetting(SettingIds.GRID_COLUMNS_THRESHOLD, 350);
const containerWidth = store.ui.sidebarContainer?.clientWidth || 0;
if (containerWidth > 0) {
currentColumnCount = calculateGridColumns(containerWidth, threshold);
}
mountCards(store.ui.cardStack, bannerElements, cardElements, currentColumnCount);
if (store.ui.mainQueueScrollTop !== null) {
const restorePos = store.ui.mainQueueScrollTop;
store.ui.mainQueueScrollTop = null;
const scrollEl = getScrollContainer(store.ui.cardStack);
if (scrollEl) {
scrollEl.scrollTop = restorePos;
requestAnimationFrame(() => { scrollEl.scrollTop = restorePos; });
}
}
updateScrollTopBtnVisibility();
});
}
export function teardownSidebarView() {
window.removeEventListener("keydown", handleGlobalKeyDown);
window.removeEventListener("keyup", handleGlobalKeyUp);
window.removeEventListener("blur", handleGlobalBlur);
document.removeEventListener("click", handleGlobalClick, true);
document.removeEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
if (activeTimerInterval) {
clearInterval(activeTimerInterval);
activeTimerInterval = null;
}
if (renderAnimationFrameId) {
cancelAnimationFrame(renderAnimationFrameId);
renderAnimationFrameId = null;
}
cardPool.clear();
if (scrollToTopBtnEl && scrollToTopBtnEl.parentNode) {
scrollToTopBtnEl.parentNode.removeChild(scrollToTopBtnEl);
}
scrollToTopBtnEl = null;
}