diff --git a/js/queue.js b/js/queue.js
index 04882bd..5801319 100644
--- a/js/queue.js
+++ b/js/queue.js
@@ -80,6 +80,7 @@ const concludeRun = async (pid, statusStr) => {
const hItem = await res.json();
if (hItem && hItem[pid]) {
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
+ st.nodeOutputs = hItem[pid].outputs; // Store full outputs dictionary!
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs);
st.texts = findTextsInOutputs(hItem[pid].outputs);
}
@@ -174,6 +175,11 @@ export function setupApiListeners() {
// Overwrite with latest texts to avoid clearing previously set text outputs
st.texts = finalTexts;
}
+
+ // Dynamically capture each executed node's individual outputs
+ if (!st.nodeOutputs) st.nodeOutputs = {};
+ st.nodeOutputs[e.detail.node] = e.detail.output;
+
renderDOMFn();
}
});
@@ -215,6 +221,7 @@ export async function initSessionAndHistory() {
State.globalOrderCounter++;
promptStates.set(id, {
pid: id, status: "completed", images, texts,
+ nodeOutputs: historyData[id].outputs, // Store full outputs dictionary!
workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null,
progressText: "", timestamp: State.globalOrderCounter, rendered: true
});
diff --git a/js/state.js b/js/state.js
index 4f47bbf..fd635ec 100644
--- a/js/state.js
+++ b/js/state.js
@@ -7,7 +7,9 @@ export const State = {
globalOrderCounter: 0,
sidebarContainer: null,
cardStack: null,
- currentlyActivePromptId: null
+ currentlyActivePromptId: null,
+ activeSubmenuPromptId: null, // Active run/outputs explorer pointer
+ activeSubmenuBatchImages: null // Active batch images explorer pointer
};
export function pruneHistory(app) {
@@ -38,6 +40,7 @@ export function saveStatesToLocalStorage() {
serializable.push({
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
+ nodeOutputs: state.nodeOutputs, // Persist node level outputs
workflow: state.workflow, progress: state.progress || 0, queueNumber: state.queueNumber,
progressText: state.progressText || "", timestamp: state.timestamp,
activeNodeName: state.activeNodeName || "", rendered: state.rendered || false,
diff --git a/js/ui.js b/js/ui.js
index 20abadf..38f0ce9 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -1,7 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
-import { isVideoFormat, matchesFilter } from "./utils.js";
+import { isVideoFormat, matchesFilter, getRunOutputs } from "./utils.js";
// DI Hook for cyclic imports
export let syncQueueFn = async () => {};
@@ -238,6 +238,19 @@ export function setupSidebarUI() {
Object.assign(State.cardStack.style, { flex: "1", overflowY: "auto", scrollbarWidth: "thin", display: "block" });
State.sidebarContainer.appendChild(State.cardStack);
+ // Global background click handler: return to main queue if clicking empty space of the entire container
+ State.sidebarContainer.onclick = (e) => {
+ if (State.activeSubmenuPromptId || State.activeSubmenuBatchImages) {
+ // Stop closure if we clicked on standard interactive elements (images, buttons, spans, cancels)
+ if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return;
+ State.activeSubmenuPromptId = null;
+ State.activeSubmenuBatchImages = null;
+ renderDOM();
+ document.removeEventListener("click", handleGlobalClick, true);
+ globalClickRegistered = false;
+ }
+ };
+
new ResizeObserver((entries) => {
const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350;
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
@@ -257,6 +270,254 @@ export function setupSidebarUI() {
return State.sidebarContainer;
}
+// Unified image batch pagination renderer (supports standard queue cards & both explorer submenus)
+function renderCardImages(cardObj, state, keepAspect) {
+ cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
+ if (cardObj.currentImageIndex >= state.images.length) {
+ cardObj.currentImageIndex = 0;
+ }
+
+ const idx = cardObj.currentImageIndex;
+ const img = state.images[idx];
+ if (!img) {
+ cardObj.grid.innerHTML = "";
+ cardObj.firstImgElement = null;
+ return;
+ }
+
+ const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
+ const isVideo = isVideoFormat(src);
+
+ const currentMediaEl = cardObj.grid.querySelector("img, video");
+ const matchesTagType = currentMediaEl && (isVideo === (currentMediaEl.tagName.toLowerCase() === "video"));
+
+ if (matchesTagType) {
+ // --- IN-PLACE REUSE PATH (ELIMINATES BLINKING & SHAPE-SHIFTS) ---
+ const mediaEl = currentMediaEl;
+
+ // 1. Update interactive event listeners on reuse to bind correct closure variables
+ mediaEl.onclick = (ev) => {
+ ev.stopPropagation();
+ showFullscreenPreview(state.images.map(i => i.url ? i.url : window.location.origin + `/view?filename=${encodeURIComponent(i.filename)}&type=${encodeURIComponent(i.type || 'output')}&subfolder=${encodeURIComponent(i.subfolder || '')}`));
+ };
+
+ if (!isVideo) {
+ // Re-bind native drag handlers with updated state values
+ const newDragStart = (e) => {
+ State.currentDraggedImgData = { ...img, workflow: state.workflow };
+ try {
+ e.dataTransfer.setData("text/uri-list", src);
+ e.dataTransfer.setData("text/plain", src);
+ } catch (err) {}
+ e.dataTransfer.effectAllowed = "copy";
+ e.stopPropagation();
+ };
+ mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
+ mediaEl.addEventListener("dragstart", newDragStart);
+ mediaEl._currentDragStart = newDragStart;
+ }
+
+ // 2. Perform smooth in-memory source swapping for blob live previews
+ const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
+ if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
+ if (src.startsWith("blob:") && !isVideo) {
+ // Smooth preload trick: hold current preview until the new one is fully loaded
+ const tempImg = new Image();
+ tempImg.onload = () => {
+ const oldBlob = mediaEl._lastBlob;
+ mediaEl.src = src;
+ mediaEl._lastBlob = src;
+
+ if (keepAspect && tempImg.naturalWidth && tempImg.naturalHeight) {
+ mediaEl.style.aspectRatio = `${tempImg.naturalWidth} / ${tempImg.naturalHeight}`;
+ }
+ if (cardObj.dimEl && tempImg.naturalWidth && tempImg.naturalHeight) {
+ cardObj.dimEl.textContent = `${tempImg.naturalWidth}x${tempImg.naturalHeight}`;
+ cardObj.dimEl.style.display = "block";
+ }
+
+ if (oldBlob && oldBlob !== src) {
+ try { URL.revokeObjectURL(oldBlob); } catch(e){}
+ }
+ };
+ tempImg.src = src;
+ } else {
+ // Direct fast swap for standard server files
+ mediaEl.src = isVideo ? src + "#t=0.001" : src;
+ }
+ }
+
+ // 3. Update the pagination label if it exists
+ const label = cardObj.grid.querySelector(".comfy-sidebar-batch-label");
+ if (label) {
+ label.textContent = `${idx + 1}/${state.images.length}`;
+ }
+ return;
+ }
+
+ // --- REBUILD PATH (ONLY RUNS ON TYPE CHANGE OR FIRST RENDER) ---
+ cardObj.grid.innerHTML = "";
+ cardObj.firstImgElement = null;
+
+ const wrapper = document.createElement("div");
+ Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
+
+ const thumb = isVideo ? document.createElement("video") : document.createElement("img");
+ Object.assign(thumb.style, {
+ width: "100%",
+ borderRadius: "2px",
+ display: "block",
+ cursor: "zoom-in",
+ webkitUserDrag: "element"
+ });
+
+ if (isVideo) {
+ thumb.muted = true;
+ thumb.playsInline = true;
+ thumb.preload = "metadata";
+ thumb.loop = true;
+
+ const playIcon = document.createElement("div");
+ Object.assign(playIcon.style, {
+ position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
+ pointerEvents: "none", zIndex: "5", transition: "opacity 0.2s ease"
+ });
+
+ playIcon.innerHTML = `
+
+ `;
+ wrapper.appendChild(playIcon);
+
+ thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
+ thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
+ }
+
+ if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
+
+ if (!isVideo) {
+ thumb.setAttribute("draggable", "true");
+ thumb.onload = () => {
+ if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) {
+ thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`;
+ }
+ if (cardObj.dimEl) {
+ cardObj.dimEl.textContent = `${thumb.naturalWidth}x${thumb.naturalHeight}`;
+ cardObj.dimEl.style.display = "block";
+ }
+ };
+ thumb.src = src;
+
+ const dragStartHandler = (e) => {
+ State.currentDraggedImgData = { ...img, workflow: state.workflow };
+ try {
+ e.dataTransfer.setData("text/uri-list", src);
+ e.dataTransfer.setData("text/plain", src);
+ } catch (err) {}
+ e.dataTransfer.effectAllowed = "copy";
+ e.stopPropagation();
+ };
+ thumb.addEventListener("dragstart", dragStartHandler);
+ thumb._currentDragStart = dragStartHandler;
+ } else {
+ thumb.setAttribute("draggable", "false");
+ thumb.onloadedmetadata = () => {
+ if (keepAspect && thumb.videoWidth && thumb.videoHeight) {
+ thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`;
+ }
+ if (cardObj.dimEl) {
+ cardObj.dimEl.textContent = `${thumb.videoWidth}x${thumb.videoHeight}`;
+ cardObj.dimEl.style.display = "block";
+ }
+ };
+ thumb.src = src + "#t=0.001";
+ }
+
+ thumb.onclick = (ev) => {
+ ev.stopPropagation();
+ showFullscreenPreview(state.images.map(i => i.url ? i.url : window.location.origin + `/view?filename=${encodeURIComponent(i.filename)}&type=${encodeURIComponent(i.type || 'output')}&subfolder=${encodeURIComponent(i.subfolder || '')}`));
+ };
+
+ wrapper.insertBefore(thumb, wrapper.firstChild);
+ cardObj.firstImgElement = thumb;
+
+ // Overlay slick pagination controller bar if batch has multiple output images
+ if (state.images.length > 1) {
+ const navBar = document.createElement("div");
+ Object.assign(navBar.style, {
+ position: "absolute", bottom: "6px", left: "50%", transform: "translateX(-50%)",
+ display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
+ padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
+ fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
+ boxShadow: "0 1px 3px rgba(0,0,0,0.4)"
+ });
+
+ const prevBtn = document.createElement("span");
+ prevBtn.className = "pi pi-chevron-left";
+ prevBtn.style.cursor = "pointer";
+ prevBtn.onclick = (ev) => {
+ ev.stopPropagation();
+ cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
+ renderCardImages(cardObj, state, keepAspect);
+ };
+
+ const label = document.createElement("span");
+ label.className = "comfy-sidebar-batch-label";
+ label.textContent = `${idx + 1}/${state.images.length}`;
+ label.style.cursor = "pointer";
+ label.title = "View all images in this batch";
+
+ // Open the dynamic batch explorer explorer view when the middle counter label is clicked
+ label.onclick = (ev) => {
+ ev.stopPropagation();
+ State.activeSubmenuBatchImages = {
+ pid: state.pid || cardObj.element.id.replace("card-", ""),
+ images: state.images,
+ workflow: state.workflow
+ };
+ renderDOM();
+ };
+
+ const nextBtn = document.createElement("span");
+ nextBtn.className = "pi pi-chevron-right";
+ nextBtn.style.cursor = "pointer";
+ nextBtn.onclick = (ev) => {
+ ev.stopPropagation();
+ cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
+ renderCardImages(cardObj, state, keepAspect);
+ };
+
+ navBar.append(prevBtn, label, nextBtn);
+ wrapper.appendChild(navBar);
+ }
+
+ cardObj.grid.appendChild(wrapper);
+}
+
+let globalClickRegistered = false;
+
+// Global document mousedown-capturer to reliably trigger click-aways outside the sidebar
+const handleGlobalClick = (e) => {
+ if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
+ document.removeEventListener("click", handleGlobalClick, true);
+ globalClickRegistered = false;
+ return;
+ }
+ const sidebar = State.sidebarContainer;
+ const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
+ const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 10000"]');
+
+ if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
+ State.activeSubmenuPromptId = null;
+ State.activeSubmenuBatchImages = null;
+ renderDOM();
+ document.removeEventListener("click", handleGlobalClick, true);
+ globalClickRegistered = false;
+ }
+};
+
let renderTimeout = null;
export function renderDOM() {
if (renderTimeout) cancelAnimationFrame(renderTimeout);
@@ -265,6 +526,244 @@ export function renderDOM() {
const keepAspect = app.ui.settings.getSettingValue("Comfy Sidebar.Keep Object Aspect Ratio") ?? true;
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
+ const headerTitle = State.sidebarContainer.querySelector("h3");
+ const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
+ const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
+
+ const styleRightBtn = (btn) => {
+ Object.assign(btn.style, {
+ display: "inline-flex",
+ alignItems: "center", justifyContent: "center",
+ width: "32px", height: "32px",
+ backgroundColor: "rgba(0, 0, 0, 0.75)",
+ color: "#e2e8f0",
+ fontSize: "14px",
+ borderRadius: "6px",
+ cursor: "pointer",
+ transition: "all 0.15s ease",
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)"
+ });
+ btn.onmouseenter = () => { btn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; btn.style.color = "#fff"; };
+ btn.onmouseleave = () => { btn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; btn.style.color = "#e2e8f0"; };
+ };
+
+ // --- SUBMENU 2: BATCH IMAGES EXPLORER ---
+ if (State.activeSubmenuBatchImages) {
+ const batchInfo = State.activeSubmenuBatchImages;
+
+ if (headerTitle) {
+ headerTitle.textContent = `Batch of #${batchInfo.pid}`;
+ headerTitle.style.cursor = "pointer";
+ headerTitle.title = "Go Back to Queue";
+ headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; renderDOM(); };
+ }
+ 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 = cardElements.get(cardId);
+
+ if (!cardObj) {
+ const card = document.createElement("div");
+ 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"
+ });
+
+ const grid = document.createElement("div");
+ grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
+
+ const p = document.createElement("div");
+ Object.assign(p.style, {
+ fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
+ });
+
+ // Symmetrical hover panel on the right with ONLY the Download Object button
+ const hoverPanel = document.createElement("div");
+ Object.assign(hoverPanel.style, {
+ position: "absolute", bottom: "4px", right: "4px", display: "none",
+ flexDirection: "column", gap: "4px", zIndex: "20"
+ });
+
+ const btnImg = document.createElement("span");
+ btnImg.className = "pi pi-image";
+ btnImg.title = "Download Object";
+ styleRightBtn(btnImg);
+ hoverPanel.appendChild(btnImg);
+
+ card.append(timerEl, dimEl, grid, p, hoverPanel);
+ cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
+ cardElements.set(cardId, cardObj);
+
+ card.addEventListener("mouseenter", () => {
+ cardObj.hoverPanel.style.display = "flex";
+ });
+ card.addEventListener("mouseleave", () => {
+ cardObj.hoverPanel.style.display = "none";
+ });
+ }
+
+ cardObj.placeholder.style.display = "none";
+
+ cardObj.btnImg.style.display = "inline-flex";
+ cardObj.btnImg.onclick = (ev) => {
+ ev.stopPropagation();
+ const a = document.createElement("a");
+ a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
+ a.download = img.filename || "output";
+ a.click();
+ };
+
+ renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow }, keepAspect);
+
+ targetElements.push(cardObj.element);
+ });
+
+ targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
+ while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
+ return;
+ }
+
+ // --- SUBMENU 1: WORKFLOW OUTPUTS EXPLORER ---
+ if (State.activeSubmenuPromptId) {
+ const st = promptStates.get(State.activeSubmenuPromptId);
+ if (!st) {
+ State.activeSubmenuPromptId = null;
+ renderDOM();
+ return;
+ }
+
+ // Sync Header UI State for the explorer view
+ if (headerTitle) {
+ headerTitle.textContent = `Outputs of #${State.activeSubmenuPromptId}`;
+ headerTitle.style.cursor = "pointer";
+ headerTitle.title = "Go Back to Queue";
+ headerTitle.onclick = () => { State.activeSubmenuPromptId = null; renderDOM(); };
+ }
+ if (headerSearchIcon) headerSearchIcon.style.display = "none";
+ if (headerActions) headerActions.style.display = "none";
+
+ if (!globalClickRegistered) {
+ document.addEventListener("click", handleGlobalClick, true);
+ globalClickRegistered = true;
+ }
+
+ const outputs = getRunOutputs(st.nodeOutputs);
+ const targetElements = [];
+
+ outputs.forEach((out) => {
+ const cardId = `submenu-${st.pid}-${out.nodeId}`;
+ let cardObj = cardElements.get(cardId);
+
+ if (!cardObj) {
+ const card = document.createElement("div");
+ card.className = "comfy-sidebar-card completed";
+ card.style.position = "relative";
+
+ const timerEl = document.createElement("div");
+ timerEl.className = "comfy-sidebar-card-timer";
+ const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId));
+ timerEl.textContent = node ? (node.title || node.type) : `Node #${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"
+ });
+
+ const grid = document.createElement("div");
+ grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
+
+ const p = document.createElement("div");
+ Object.assign(p.style, {
+ fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
+ });
+
+ // Action overlay on hover containing only Download Object
+ const hoverPanel = document.createElement("div");
+ Object.assign(hoverPanel.style, {
+ position: "absolute", bottom: "4px", right: "4px", display: "none",
+ flexDirection: "column", gap: "4px", zIndex: "20"
+ });
+
+ const btnImg = document.createElement("span");
+ btnImg.className = "pi pi-image";
+ btnImg.title = "Download Object";
+ styleRightBtn(btnImg);
+ hoverPanel.appendChild(btnImg);
+
+ card.append(timerEl, dimEl, grid, p, hoverPanel);
+ cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
+ cardElements.set(cardId, cardObj);
+
+ card.addEventListener("mouseenter", () => {
+ cardObj.hoverPanel.style.display = "flex";
+ });
+ card.addEventListener("mouseleave", () => {
+ cardObj.hoverPanel.style.display = "none";
+ });
+ }
+
+ if (out.images && out.images.length > 0) {
+ cardObj.placeholder.style.display = "none";
+
+ cardObj.btnImg.style.display = "inline-flex";
+ cardObj.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}&subfolder=${encodeURIComponent(img.subfolder)}`;
+ a.download = img.filename || "output";
+ a.click();
+ });
+ };
+
+ renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow }, keepAspect);
+ } else {
+ cardObj.btnImg.style.display = "none";
+ cardObj.placeholder.style.display = "block";
+ cardObj.placeholder.textContent = "No Outputs";
+ }
+
+ targetElements.push(cardObj.element);
+ });
+
+ targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
+ while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
+ return;
+ }
+
+ // --- STANDARD QUEUE RENDER LOGIC ---
+ if (headerTitle) {
+ headerTitle.textContent = "Queue";
+ headerTitle.style.cursor = "default";
+ headerTitle.onclick = null;
+ }
+ if (headerSearchIcon) headerSearchIcon.style.display = "inline";
+ if (headerActions) headerActions.style.display = "flex";
+
let tasksArray = Array.from(promptStates.values());
if (showPendingSummary) tasksArray = tasksArray.filter(t => t.status !== "pending");
if (State.currentSearchQuery) tasksArray = tasksArray.filter(t => matchesFilter(t, State.currentSearchQuery));
@@ -303,27 +802,65 @@ export function renderDOM() {
const pb = document.createElement("div"); Object.assign(pb.style, { width: `0%`, height: "100%", background: "#3b82f6", transition: "width 0.1s linear" });
pt.appendChild(pb);
const statusText = document.createElement("div"); Object.assign(statusText.style, { fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px", display: "none", fontWeight: "bold" });
- const hoverPanel = document.createElement("div"); Object.assign(hoverPanel.style, { position: "absolute", bottom: "4px", right: "4px", display: "none", gap: "12px", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", zIndex: "20" });
- const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; Object.assign(btnImg.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
- const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; Object.assign(btnJson.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
- const btnDel = document.createElement("span");
- btnDel.className = "pi pi-trash";
- Object.assign(btnDel.style, {
- cursor: "pointer",
- fontSize: "20px",
- color: "#dc3545",
- transition: "all 0.1s ease-in-out",
- padding: "2px",
- transform: "scale(1)"
+ // Absolute Vertical Actions Panel on the right of the card
+ const hoverPanel = document.createElement("div");
+ Object.assign(hoverPanel.style, {
+ position: "absolute", bottom: "4px", right: "4px", display: "none",
+ flexDirection: "column", gap: "4px", zIndex: "20"
});
+
+ const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleRightBtn(btnImg);
+ const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleRightBtn(btnJson);
+ const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleRightBtn(btnDel);
+
+ // Bottom left Explorer Submenu Button
+ const leftHoverBtn = document.createElement("span");
+ leftHoverBtn.className = "pi pi-images comfy-sidebar-left-hover-btn";
+ leftHoverBtn.title = "View all intermediate outputs";
+ Object.assign(leftHoverBtn.style, {
+ position: "absolute", bottom: "4px", left: "4px", display: "none",
+ alignItems: "center", justifyContent: "center",
+ width: "32px", height: "32px",
+ backgroundColor: "rgba(0, 0, 0, 0.75)",
+ color: "#e2e8f0",
+ fontSize: "14px",
+ borderRadius: "6px",
+ cursor: "pointer",
+ transition: "all 0.15s ease",
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
+ zIndex: "20"
+ });
+ leftHoverBtn.onmouseenter = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; leftHoverBtn.style.color = "#fff"; };
+ leftHoverBtn.onmouseleave = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; leftHoverBtn.style.color = "#e2e8f0"; };
+
+ leftHoverBtn.onclick = (ev) => {
+ ev.stopPropagation();
+ State.activeSubmenuPromptId = state.pid;
+ renderDOM();
+ };
+
hoverPanel.append(btnImg, btnJson, btnDel);
- card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel);
+ card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverBtn);
- cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
+ cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
- card.addEventListener("mouseenter", () => { if (state.status !== "active" && state.status !== "pending") cardObj.hoverPanel.style.display = "flex"; });
- card.addEventListener("mouseleave", () => cardObj.hoverPanel.style.display = "none");
+ card.addEventListener("mouseenter", () => {
+ if (state.status !== "active" && state.status !== "pending") {
+ cardObj.hoverPanel.style.display = "flex";
+ // Show explorer view button if this run yielded multiple node outputs
+ const outputs = getRunOutputs(state.nodeOutputs);
+ if (outputs.length > 1) {
+ cardObj.leftHoverBtn.style.display = "inline-flex";
+ } else {
+ cardObj.leftHoverBtn.style.display = "none";
+ }
+ }
+ });
+ card.addEventListener("mouseleave", () => {
+ cardObj.hoverPanel.style.display = "none";
+ cardObj.leftHoverBtn.style.display = "none";
+ });
card.addEventListener("dragstart", (e) => {
// Custom dragstart is only used for JSON-only workflows to supply a local DownloadURL.
@@ -384,12 +921,12 @@ export function renderDOM() {
}
if (state.images && state.images.length > 0) {
- cardObj.btnImg.style.display = "inline";
+ cardObj.btnImg.style.display = "inline-flex";
cardObj.btnImg.onclick = (ev) => { ev.stopPropagation(); state.images.forEach(img => { const a = document.createElement("a"); a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; a.download = img.filename || "output"; a.click(); }); };
} else cardObj.btnImg.style.display = "none";
if (state.workflow) {
- cardObj.btnJson.style.display = "inline";
+ cardObj.btnJson.style.display = "inline-flex";
cardObj.btnJson.onclick = (ev) => { ev.stopPropagation(); const blob = new Blob([JSON.stringify(state.workflow, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `workflow_${state.pid}.json`; a.click(); URL.revokeObjectURL(url); };
} else cardObj.btnJson.style.display = "none";
@@ -397,12 +934,10 @@ export function renderDOM() {
const resetDeleteBtn = () => {
isDeletePending = false;
Object.assign(cardObj.btnDel.style, {
- color: "#dc3545",
- background: "none",
- padding: "2px",
- transform: "scale(1)"
+ color: "#e2e8f0",
+ backgroundColor: "rgba(0, 0, 0, 0.75)"
});
- cardObj.btnDel.title = "Delete Element from History";
+ cardObj.btnDel.title = "Delete Card";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
@@ -412,10 +947,7 @@ export function renderDOM() {
isDeletePending = true;
Object.assign(cardObj.btnDel.style, {
color: "#fff",
- background: "#dc3545",
- borderRadius: "4px",
- padding: "2px",
- transform: "scale(1.2)"
+ backgroundColor: "#dc3545" // Changes to solid red on warning confirmation
});
cardObj.btnDel.title = "Click again to confirm deletion";
deleteTimeout = setTimeout(resetDeleteBtn, 1000);
@@ -433,116 +965,7 @@ export function renderDOM() {
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null; cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
- // Check if we can reuse elements (count matches AND tag types match)
- let canReuse = cardObj.grid.children.length === state.images.length;
- if (canReuse) {
- state.images.forEach((img, idx) => {
- const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
- const wrapper = cardObj.grid.children[idx];
- // Fallback in case the DOM still has old unwrapped elements
- const mediaEl = (wrapper.tagName === "IMG" || wrapper.tagName === "VIDEO") ? wrapper : wrapper.querySelector("img, video");
- if (!mediaEl || isVideoFormat(src) !== (mediaEl.tagName.toLowerCase() === "video")) canReuse = false;
- });
- }
-
- if (canReuse) {
- state.images.forEach((img, idx) => {
- let src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
- const isVideo = isVideoFormat(src);
- if (isVideo) src = src + "#t=0.001"; // Keep the first-frame trick on update
-
- const wrapper = cardObj.grid.children[idx];
- const mediaEl = (wrapper.tagName === "IMG" || wrapper.tagName === "VIDEO") ? wrapper : wrapper.querySelector("img, video");
-
- if (mediaEl.getAttribute("src") !== src && mediaEl.src !== src) {
- if (src.startsWith("blob:") && !isVideo) {
- const tempImg = new Image();
- tempImg.onload = () => {
- const oldBlob = mediaEl._lastBlob;
- mediaEl.src = src;
- mediaEl._lastBlob = src;
- if (oldBlob && oldBlob !== src) try { URL.revokeObjectURL(oldBlob); } catch(e){}
- };
- tempImg.src = src;
- }
- else mediaEl.src = src;
- }
- });
- } else {
- cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null;
- state.images.forEach(img => {
- const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
- const isVideo = isVideoFormat(src);
-
- // Container to hold both the media and the play icon
- const wrapper = document.createElement("div");
- Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
-
- const thumb = isVideo ? document.createElement("video") : document.createElement("img");
-
- // Fully-qualified absolute same-origin HTTP URLs mapped alongside webkitUserDrag force flags
- Object.assign(thumb.style, {
- width: "100%",
- borderRadius: "2px",
- display: "block",
- cursor: "zoom-in",
- webkitUserDrag: "element"
- });
-
- if (isVideo) {
- thumb.muted = true;
- thumb.playsInline = true;
- thumb.preload = "metadata";
- thumb.loop = true;
-
- // Create the Play Icon overlay with perfectly centered scalable SVG
- const playIcon = document.createElement("div");
- Object.assign(playIcon.style, {
- position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
- pointerEvents: "none", zIndex: "5", transition: "opacity 0.2s ease"
- });
-
- // The radius 16.65 ensures the circle is exactly 1/3 of the shorter edge.
- // The polygon coordinates are an optically centered equilateral triangle.
- playIcon.innerHTML = `
-
- `;
- wrapper.appendChild(playIcon);
-
- // Play on Hover, Pause on Leave, and fade out the play button
- thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
- thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
- }
-
- if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
-
- // Keep native image tags naturally draggable
- if (!isVideo) {
- thumb.setAttribute("draggable", "true");
- } else {
- thumb.setAttribute("draggable", "false");
- }
-
- if (!isVideo) {
- thumb.onload = () => { if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`; };
- if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { thumb.src = src; thumb._lastBlob = src; }; tempImg.src = src; } else thumb.src = src;
- } else {
- thumb.onloadedmetadata = () => { if (keepAspect && thumb.videoWidth && thumb.videoHeight) thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`; };
- thumb.src = src + "#t=0.001";
- }
- thumb.onclick = (ev) => {
- ev.stopPropagation();
- showFullscreenPreview(state.images.map(i => i.url ? i.url : window.location.origin + `/view?filename=${encodeURIComponent(i.filename)}&type=${encodeURIComponent(i.type || 'output')}&subfolder=${encodeURIComponent(i.subfolder || '')}`));
- };
-
- wrapper.insertBefore(thumb, wrapper.firstChild);
- if (!cardObj.firstImgElement) cardObj.firstImgElement = thumb;
- cardObj.grid.appendChild(wrapper);
- });
- }
+ renderCardImages(cardObj, state, keepAspect);
}
cardObj.lastImagesSignature = currentImagesSignature;
}
diff --git a/js/utils.js b/js/utils.js
index 88ff431..bca6b54 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -45,6 +45,19 @@ export function findTextsInOutputs(outputs) {
return list;
}
+// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
+export function getRunOutputs(nodeOutputs) {
+ const list = [];
+ if (!nodeOutputs) return list;
+ for (const nodeId in nodeOutputs) {
+ const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] });
+ if (imgs.length > 0) {
+ list.push({ nodeId, images: imgs });
+ }
+ }
+ return list;
+}
+
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();