Compare commits

...
5 Commits
Author SHA1 Message Date
m0rtus59 c43863a219 Modify preview compare slider instructions in README
Updated the instructions for using the preview compare slider to include the shift+click action.
2026-07-19 09:56:48 +05:00
M0rtus59 111de1a8c2 Improved comparison 2026-07-19 09:55:12 +05:00
M0rtus59 92653bfa75 minor fixes 2026-07-19 00:56:46 +05:00
M0rtus59 bf2cdc4bcd Add show node button 2026-07-19 00:39:00 +05:00
M0rtus59 d85c54232a Fixed missing batch arrows 2026-07-19 00:21:04 +05:00
3 changed files with 371 additions and 248 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ A lightweight, high-performance ComfyUI extension that replaces the cluttered st
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
* A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
* Toggles to hide buttons you don't need from the side panel.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then click another to load it alongside the first one for pixel by pixel comparison.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then shift+click another to load it alongside the first one for pixel by pixel comparison.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
+58 -41
View File
@@ -5,6 +5,9 @@ let activeComparisonViewer = null;
let videoSyncActive = false;
let syncAnimationFrameId = null;
// Track the global keydown reference across instances defensively
let globalKeydownHandler = null;
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
@@ -13,7 +16,7 @@ const createMediaElement = (src, muted = false) => {
el.playsInline = true;
el.autoplay = true;
el.loop = true;
el.controls = false; // Disable native controls to prevent layout shifts
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
@@ -23,7 +26,6 @@ const createMediaElement = (src, muted = false) => {
return el;
};
// Video Playback Manager: Binds timeline progress bar, timeline scrubbing, and play/pause triggers
const setupVideoPlayback = (vid, container) => {
if (!vid) return;
@@ -33,10 +35,9 @@ const setupVideoPlayback = (vid, container) => {
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
// Synced Scrubber Progress Bar Overlay
const controlBar = document.createElement("div");
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
position: "absolute", bottom: "16px", left: "50%",
display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px",
@@ -102,7 +103,6 @@ const setupVideoPlayback = (vid, container) => {
const syncLoop = () => {
if (!videoSyncActive) return;
// Update progress & labels
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
@@ -115,9 +115,7 @@ const setupVideoPlayback = (vid, container) => {
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
// Dynamically match play button in case video loops
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
requestAnimationFrame(syncLoop);
@@ -133,14 +131,16 @@ const setupVideoPlayback = (vid, container) => {
};
function createComparisonViewer(baseSrc) {
const isBaseVideo = isVideoFormat(baseSrc);
// DEFENSIVE CHECK: Force-remove any globally orphaned keydown handlers before binding a new one
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
// Mount the viewer overlay container directly inside ComfyUI's canvas parent.
// This allows menus/sidebars outside the canvas area to naturally render on top.
const isBaseVideo = isVideoFormat(baseSrc);
const canvasEl = document.querySelector("#graph-canvas, canvas");
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
// Ensure the canvas container is positioned relatively so our overlay stretches accurately
if (targetContainer && targetContainer !== document.body) {
targetContainer.style.position = "relative";
}
@@ -151,7 +151,7 @@ function createComparisonViewer(baseSrc) {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
zIndex: "10", // Above base LGraph grid but strictly below other UI components
zIndex: "10",
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
});
@@ -164,11 +164,10 @@ function createComparisonViewer(baseSrc) {
const infoText = document.createElement("span");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Reference loaded. Click another card image in the sidebar to compare.";
: "Reference loaded. Click to replace | Shift+Click another card image to compare.";
header.appendChild(infoText);
container.appendChild(header);
// Close button
const closeBtn = document.createElement("span");
closeBtn.className = "pi pi-times";
closeBtn.title = "Close Comparison (Esc)";
@@ -180,7 +179,6 @@ function createComparisonViewer(baseSrc) {
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
container.appendChild(closeBtn);
// Workspace wrapper holding both elements aligned pixel-perfectly
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
@@ -188,21 +186,30 @@ function createComparisonViewer(baseSrc) {
});
container.appendChild(wrapper);
// Close strictly on clicking empty background space of the overlay backdrop
// Muted bottom tooltip helper
const hintPrompt = document.createElement("div");
Object.assign(hintPrompt.style, {
position: "absolute", bottom: "16px", zIndex: "30",
color: "#555", fontSize: "10px", fontFamily: "sans-serif",
pointerEvents: "none"
});
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#777;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
if (isBaseVideo) {
hintPrompt.style.display = "none";
}
container.appendChild(hintPrompt);
container.onclick = (e) => {
if (e.target === container) {
destroy();
}
};
// Media A (Base reference - Left. Created unmuted to enable audio playback)
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
// Media B (Comparison target - Right)
let mediaB = null;
// Draggable Split Slider Handle
const slider = document.createElement("div");
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
@@ -223,7 +230,7 @@ function createComparisonViewer(baseSrc) {
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
let splitRatio = 50; // Percentage
let splitRatio = 50;
let isDragging = false;
let destroyVideoPlaybackFn = null;
@@ -231,7 +238,6 @@ function createComparisonViewer(baseSrc) {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
if (mediaB) {
// Symmetrical split comparison layout: Image A on Left, Image B on Right
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
}
};
@@ -241,7 +247,6 @@ function createComparisonViewer(baseSrc) {
e.stopPropagation();
isDragging = true;
// Symmetrical Click Teleportation: Snaps split-line directly to mouse coordinates
const rect = wrapper.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const percent = ((clientX - rect.left) / rect.width) * 100;
@@ -256,7 +261,6 @@ function createComparisonViewer(baseSrc) {
};
const endDrag = () => { isDragging = false; };
// Allows dragging/clicking from any region of the wrapper
wrapper.addEventListener("mousedown", startDrag);
wrapper.addEventListener("touchstart", startDrag);
@@ -270,7 +274,11 @@ function createComparisonViewer(baseSrc) {
window.removeEventListener("touchmove", doDrag);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
document.removeEventListener("keydown", handleKeys);
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
videoSyncActive = false;
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
@@ -289,53 +297,62 @@ function createComparisonViewer(baseSrc) {
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
};
document.addEventListener("keydown", handleKeys);
globalKeydownHandler = handleKeys;
document.addEventListener("keydown", globalKeydownHandler);
targetContainer.appendChild(container);
// Initialize single-video playbacks if the base output is an MP4
if (isBaseVideo) {
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
}
return {
loadTarget(targetSrc) {
loadTarget(targetSrc, isShiftClick) {
const isTargetVideo = isVideoFormat(targetSrc);
if (isBaseVideo) {
// If in Video Playback Mode: clicking another item replaces the player or opens image comparison
destroy();
showFullscreenPreview([targetSrc]);
showFullscreenPreview([targetSrc], isShiftClick);
return;
}
// If in Image Comparison Mode: ignore video targets ("images compare only to other images")
if (isTargetVideo) {
return;
}
if (mediaB) mediaB.remove();
if (isShiftClick) {
// Shift+Click: Sideload target image B for comparison split
if (mediaB) mediaB.remove();
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
wrapper.appendChild(mediaB);
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
wrapper.appendChild(mediaB);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Click other card images to update target | Esc to close.";
updateSliderPosition(50);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close.";
updateSliderPosition(50);
} else {
// Standard Click: Replace the main base reference image entirely
if (mediaB) {
mediaB.remove();
mediaB = null;
}
slider.style.display = "none";
mediaA.src = targetSrc;
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
}
}
};
}
export function showFullscreenPreview(imgSrcs) {
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
if (!imgSrcs || imgSrcs.length === 0) return;
// If the comparison overlay is already open, dynamically load the target
if (activeComparisonViewer) {
activeComparisonViewer.loadTarget(imgSrcs[0]);
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
return;
}
// Otherwise, open a fresh side-by-side comparison workspace
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
}
+312 -206
View File
@@ -8,6 +8,34 @@ import { showFullscreenPreview } from "./comparison.js";
export let syncQueueFn = async () => {};
export function setSyncQueue(fn) { syncQueueFn = fn; }
// Resets any stale hover states when transitioning between submenus and the main queue
function resetAllCardHoverStates() {
for (const cardObj of cardElements.values()) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
}
}
// Traverses node execution metadata to resolve the exact node ID that generated a specific image
function findNodeIdForImage(state, img) {
if (!state || !state.nodeOutputs || !img) return null;
for (const nodeId in state.nodeOutputs) {
const out = state.nodeOutputs[nodeId];
// Safely check both gifs, images, or videos under this specific node
for (const key in out) {
const val = out[key];
if (Array.isArray(val)) {
if (val.some(i => i && typeof i === 'object' && i.filename === img.filename)) {
return nodeId;
}
} else if (val && typeof val === 'object' && val.filename === img.filename) {
return nodeId;
}
}
}
return null;
}
export const updateSidebarBadge = (count) => {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
@@ -216,6 +244,7 @@ export function setupSidebarUI() {
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;
resetAllCardHoverStates(); // Instantly wipe stale hover indicators on exit click
renderDOM();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
@@ -253,198 +282,199 @@ function renderCardImages(cardObj, state, keepAspect) {
if (!img) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
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 ---
const mediaEl = currentMediaEl;
// 1. Update interactive event listeners on reuse to bind correct closure variables
mediaEl.onclick = (ev) => {
ev.stopPropagation();
showFullscreenPreview([src]);
};
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;
// Find or create the native wrapper
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
if (!wrapper) {
cardObj.grid.innerHTML = "";
wrapper = document.createElement("div");
wrapper.className = "comfy-sidebar-media-wrapper";
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
cardObj.grid.appendChild(wrapper);
}
// --- REBUILD PATH (ONLY RUNS ON TYPE CHANGE OR FIRST RENDER) ---
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
// Find or create the media tag in-place
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
if (needsRebuild) {
if (mediaEl) mediaEl.remove();
mediaEl = isVideo ? document.createElement("video") : document.createElement("img");
Object.assign(mediaEl.style, {
width: "100%",
borderRadius: "2px",
display: "block",
cursor: "zoom-in",
webkitUserDrag: "element",
zIndex: "1"
});
wrapper.insertBefore(mediaEl, wrapper.firstChild);
}
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
// Explicit low stacking index combined with standard native webkit dragging force rules
Object.assign(thumb.style, {
width: "100%",
borderRadius: "2px",
display: "block",
cursor: "zoom-in",
webkitUserDrag: "element",
zIndex: "1"
});
cardObj.firstImgElement = mediaEl;
// Binds interactive event listeners. Passes shiftKey context natively to support split side-loading
mediaEl.onclick = (ev) => {
ev.stopPropagation();
showFullscreenPreview([src], ev.shiftKey);
};
// Shared dimensions layout compiler
const applyDimensions = (width, height) => {
if (keepAspect && width && height) {
mediaEl.style.aspectRatio = `${width} / ${height}`;
}
if (cardObj.dimEl && width && height) {
cardObj.dimEl.textContent = `${width}x${height}`;
cardObj.dimEl.style.display = "block";
}
};
if (isVideo) {
thumb.muted = true;
thumb.playsInline = true;
thumb.preload = "metadata";
thumb.loop = true;
mediaEl.muted = true;
mediaEl.playsInline = true;
mediaEl.preload = "metadata";
mediaEl.loop = true;
mediaEl.setAttribute("draggable", "false");
mediaEl.onloadedmetadata = () => {
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
};
const playIcon = document.createElement("div");
Object.assign(playIcon.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
});
playIcon.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
</svg>
`;
wrapper.appendChild(playIcon);
let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (!playIcon) {
playIcon = document.createElement("div");
playIcon.className = "comfy-sidebar-play-icon";
Object.assign(playIcon.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
});
playIcon.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
</svg>
`;
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"; }
// Keep native image tags naturally draggable
if (!isVideo) {
thumb.setAttribute("draggable", "true");
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
} 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([src]);
};
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)", transform: "translate3d(-50%, 0, 0)" // Forces a GPU compositor stacking context to prevent video clipping
});
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);
mediaEl.setAttribute("draggable", "true");
mediaEl.onload = () => {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
};
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
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (playIcon) playIcon.remove();
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();
};
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
mediaEl.addEventListener("dragstart", dragStartHandler);
mediaEl._currentDragStart = dragStartHandler;
}
// 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) {
const tempImg = new Image();
tempImg.onload = () => {
const oldBlob = mediaEl._lastBlob;
mediaEl.src = src;
mediaEl._lastBlob = src;
applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight);
if (oldBlob && oldBlob !== src) {
try { URL.revokeObjectURL(oldBlob); } catch(e){}
}
};
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);
tempImg.src = src;
} else {
mediaEl.src = isVideo ? src + "#t=0.001" : src;
}
} else {
// If the source is already correct, make sure we still force dimensions calculations
if (isVideo) {
if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
} else {
if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
}
}
cardObj.grid.appendChild(wrapper);
// Sync batch pagination controller overlay
let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar");
if (state.images.length > 1) {
if (!navBar) {
navBar = document.createElement("div");
navBar.className = "comfy-sidebar-batch-navbar";
Object.assign(navBar.style, {
position: "absolute", bottom: "6px", left: "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)", transform: "translate3d(-50%, 0, 0)"
});
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
};
resetAllCardHoverStates(); // Prevent ghost hover handles
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);
}
const label = navBar.querySelector(".comfy-sidebar-batch-label");
if (label) label.textContent = `${idx + 1}/${state.images.length}`;
} else {
if (navBar) navBar.remove();
}
}
let globalClickRegistered = false;
@@ -463,6 +493,7 @@ const handleGlobalClick = (e) => {
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
State.activeSubmenuPromptId = null;
State.activeSubmenuBatchImages = null;
resetAllCardHoverStates(); // Reset stale overlays when clicking away
renderDOM();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
@@ -481,7 +512,7 @@ export function renderDOM() {
const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
const styleRightBtn = (btn) => {
const styleActionBtn = (btn) => {
Object.assign(btn.style, {
display: "inline-flex",
alignItems: "center", justifyContent: "center",
@@ -508,7 +539,7 @@ export function renderDOM() {
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back to Queue";
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; renderDOM(); };
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; resetAllCardHoverStates(); renderDOM(); };
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
@@ -561,18 +592,33 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image";
btnImg.title = "Download Object";
styleRightBtn(btnImg);
styleActionBtn(btnImg);
hoverPanel.appendChild(btnImg);
card.append(timerEl, dimEl, grid, p, hoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
// Symmetrical left hover panel containing ONLY btnFocus
const leftHoverPanel = document.createElement("div");
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye";
btnFocus.title = "Show Node"; // Renamed
styleActionBtn(btnFocus);
leftHoverPanel.appendChild(btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
cardElements.set(cardId, cardObj);
card.addEventListener("mouseenter", () => {
cardObj.hoverPanel.style.display = "flex";
cardObj.leftHoverPanel.style.display = "flex";
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
@@ -587,6 +633,22 @@ export function renderDOM() {
a.click();
};
// Focus/center target node inside Batch Explorer Submenu
const nodeId = findNodeIdForImage(batchInfo, img);
if (nodeId) {
cardObj.btnFocus.style.display = "inline-flex";
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
const node = app.graph.getNodeById(Number(nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
};
} else {
cardObj.btnFocus.style.display = "none";
}
renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow }, keepAspect);
targetElements.push(cardObj.element);
@@ -611,7 +673,7 @@ export function renderDOM() {
headerTitle.textContent = `Outputs of #${State.activeSubmenuPromptId}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back to Queue";
headerTitle.onclick = () => { State.activeSubmenuPromptId = null; renderDOM(); };
headerTitle.onclick = () => { State.activeSubmenuPromptId = null; resetAllCardHoverStates(); renderDOM(); };
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
@@ -666,18 +728,33 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image";
btnImg.title = "Download Object";
styleRightBtn(btnImg);
styleActionBtn(btnImg);
hoverPanel.appendChild(btnImg);
// Symmetrical left hover panel containing ONLY btnFocus
const leftHoverPanel = document.createElement("div");
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye";
btnFocus.title = "Show Node"; // Renamed
styleActionBtn(btnFocus);
leftHoverPanel.appendChild(btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
cardElements.set(cardId, cardObj);
card.addEventListener("mouseenter", () => {
cardObj.hoverPanel.style.display = "flex";
cardObj.leftHoverPanel.style.display = "flex";
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
@@ -695,6 +772,16 @@ export function renderDOM() {
});
};
// Focus/center target node inside Workflow outputs Submenu
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
const node = app.graph.getNodeById(Number(out.nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
};
renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow }, keepAspect);
} else {
cardObj.btnImg.style.display = "none";
@@ -765,45 +852,63 @@ export function renderDOM() {
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);
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleActionBtn(btnImg);
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleActionBtn(btnJson);
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleActionBtn(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",
transform: "translateZ(0)"
// Absolute Vertical Left Actions Panel containing btnFocus and leftHoverBtn
const leftHoverPanel = document.createElement("div");
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
flexDirection: "column", gap: "4px", 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"; };
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye";
btnFocus.title = "Show Node"; // Renamed
styleActionBtn(btnFocus);
const leftHoverBtn = document.createElement("span");
leftHoverBtn.className = "pi pi-images";
leftHoverBtn.title = "View all intermediate outputs";
styleActionBtn(leftHoverBtn);
leftHoverBtn.onclick = (ev) => {
ev.stopPropagation();
State.activeSubmenuPromptId = state.pid;
resetAllCardHoverStates(); // Reset standard sidebar hover states before opening submenu
renderDOM();
};
hoverPanel.append(btnImg, btnJson, btnDel);
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverBtn);
leftHoverPanel.append(btnFocus, leftHoverBtn);
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
card.addEventListener("mouseenter", () => {
if (state.status !== "active" && state.status !== "pending") {
cardObj.hoverPanel.style.display = "flex";
cardObj.leftHoverPanel.style.display = "flex";
// Focus/Center the exact node that generated the currently active displayed image
const currentImg = state.images[cardObj.currentImageIndex || 0];
const nodeId = findNodeIdForImage(state, currentImg);
if (nodeId) {
cardObj.btnFocus.style.display = "inline-flex";
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
const node = app.graph.getNodeById(Number(nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
};
} else {
cardObj.btnFocus.style.display = "none";
}
// Show explorer view button if this run yielded multiple node outputs
const outputs = getRunOutputs(state.nodeOutputs);
if (outputs.length > 1) {
@@ -815,7 +920,7 @@ export function renderDOM() {
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverBtn.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
card.addEventListener("dragstart", (e) => {
@@ -832,6 +937,7 @@ export function renderDOM() {
e.dataTransfer.effectAllowed = "copy";
}
});
cardObj.element.id = `card-${state.pid}`; // set ID helper
cardElements.set(state.pid, cardObj);
}