Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43863a219 | ||
|
|
111de1a8c2 | ||
|
|
92653bfa75 | ||
|
|
bf2cdc4bcd | ||
|
|
d85c54232a | ||
|
|
c49859a75b | ||
|
|
08454ff7db |
@@ -11,8 +11,9 @@ A lightweight, high-performance ComfyUI extension that replaces the cluttered st
|
||||
* **Quick Save Buttons**: Dedicated interactive shortcuts appear on hover, allowing you to instantly download generation outputs or export exact workflow JSON files with a single click.
|
||||
* **Compact Adaptive Design**: A clean, smart layout that automatically balances single or multi-column grids based on your sidebar width. It maximizes viewable content and respects your active screen area without wasting precious space.
|
||||
* **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.
|
||||
* **[NEW]** 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.
|
||||
* **[NEW]** Added toggles to hide buttons you don't need from the side panel.
|
||||
* 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 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" />
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { State } from "./state.js";
|
||||
import { isVideoFormat } from "./utils.js";
|
||||
|
||||
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");
|
||||
if (isVideo) {
|
||||
el.muted = muted;
|
||||
el.playsInline = true;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.controls = false;
|
||||
}
|
||||
Object.assign(el.style, {
|
||||
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
|
||||
objectFit: "contain", pointerEvents: "none"
|
||||
});
|
||||
el.src = isVideo ? src + "#t=0.001" : src;
|
||||
return el;
|
||||
};
|
||||
|
||||
const setupVideoPlayback = (vid, container) => {
|
||||
if (!vid) return;
|
||||
|
||||
const onMetaLoaded = () => {
|
||||
vid.play().catch(()=>{});
|
||||
};
|
||||
vid.addEventListener("loadedmetadata", onMetaLoaded);
|
||||
if (vid.readyState >= 1) onMetaLoaded();
|
||||
|
||||
const controlBar = document.createElement("div");
|
||||
Object.assign(controlBar.style, {
|
||||
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",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)", transform: "translate3d(-50%, 0, 0)",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("span");
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
playBtn.style.cursor = "pointer";
|
||||
playBtn.style.fontSize = "14px";
|
||||
playBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (vid.paused) {
|
||||
vid.play().catch(()=>{});
|
||||
playBtn.className = "pi pi-pause";
|
||||
} else {
|
||||
vid.pause();
|
||||
playBtn.className = "pi pi-play";
|
||||
}
|
||||
};
|
||||
|
||||
const scrubberContainer = document.createElement("div");
|
||||
Object.assign(scrubberContainer.style, {
|
||||
flex: "1", height: "4px", background: "#444", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, {
|
||||
width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px"
|
||||
});
|
||||
scrubberContainer.appendChild(scrubberFill);
|
||||
|
||||
const timeLabel = document.createElement("span");
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
const scrub = (e) => {
|
||||
const rect = scrubberContainer.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
|
||||
const duration = vid.duration || 0;
|
||||
if (duration > 0) {
|
||||
vid.currentTime = (percent / 100) * duration;
|
||||
scrubberFill.style.width = `${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
let isScrubbing = false;
|
||||
scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
|
||||
scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
|
||||
|
||||
const handleWindowMove = (e) => { if (isScrubbing) scrub(e); };
|
||||
const handleWindowUp = () => { isScrubbing = false; };
|
||||
window.addEventListener("mousemove", handleWindowMove);
|
||||
window.addEventListener("touchmove", handleWindowMove);
|
||||
window.addEventListener("mouseup", handleWindowUp);
|
||||
window.addEventListener("touchend", handleWindowUp);
|
||||
|
||||
controlBar.append(playBtn, scrubberContainer, timeLabel);
|
||||
container.appendChild(controlBar);
|
||||
|
||||
videoSyncActive = true;
|
||||
const syncLoop = () => {
|
||||
if (!videoSyncActive) return;
|
||||
|
||||
const cur = vid.currentTime || 0;
|
||||
const dur = vid.duration || 0;
|
||||
if (dur > 0) {
|
||||
scrubberFill.style.width = `${(cur / dur) * 100}%`;
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||
}
|
||||
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
syncAnimationFrameId = requestAnimationFrame(syncLoop);
|
||||
};
|
||||
requestAnimationFrame(syncLoop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleWindowMove);
|
||||
window.removeEventListener("touchmove", handleWindowMove);
|
||||
window.removeEventListener("mouseup", handleWindowUp);
|
||||
window.removeEventListener("touchend", handleWindowUp);
|
||||
vid.removeEventListener("loadedmetadata", onMetaLoaded);
|
||||
controlBar.remove();
|
||||
};
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
// DEFENSIVE CHECK: Force-remove any globally orphaned keydown handlers before binding a new one
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
const canvasEl = document.querySelector("#graph-canvas, canvas");
|
||||
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
|
||||
|
||||
if (targetContainer && targetContainer !== document.body) {
|
||||
targetContainer.style.position = "relative";
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-sidebar-comparison-overlay";
|
||||
Object.assign(container.style, {
|
||||
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",
|
||||
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
|
||||
});
|
||||
|
||||
const header = document.createElement("div");
|
||||
Object.assign(header.style, {
|
||||
position: "absolute", top: "16px", display: "flex", gap: "16px",
|
||||
zIndex: "30", color: "#aaa", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
const infoText = document.createElement("span");
|
||||
infoText.textContent = isBaseVideo
|
||||
? "Video playback. Press Esc to close."
|
||||
: "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
header.appendChild(infoText);
|
||||
container.appendChild(header);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "pi pi-times";
|
||||
closeBtn.title = "Close Comparison (Esc)";
|
||||
Object.assign(closeBtn.style, {
|
||||
position: "absolute", top: "16px", right: "24px", zIndex: "30",
|
||||
cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease"
|
||||
});
|
||||
closeBtn.onmouseenter = () => closeBtn.style.color = "#fff";
|
||||
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
maxWidth: "85%", maxHeight: "85%"
|
||||
});
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
|
||||
let mediaB = null;
|
||||
|
||||
const slider = document.createElement("div");
|
||||
Object.assign(slider.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "50%",
|
||||
width: "2px", background: "#fff", cursor: "ew-resize",
|
||||
zIndex: "25", display: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
|
||||
});
|
||||
const sliderButton = document.createElement("div");
|
||||
Object.assign(sliderButton.style, {
|
||||
position: "absolute", top: "50%", left: "50%",
|
||||
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
|
||||
borderRadius: "50%", background: "#fff", color: "#333",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto",
|
||||
userSelect: "none", webkitUserSelect: "none"
|
||||
});
|
||||
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
|
||||
slider.appendChild(sliderButton);
|
||||
wrapper.appendChild(slider);
|
||||
|
||||
let splitRatio = 50;
|
||||
let isDragging = false;
|
||||
let destroyVideoPlaybackFn = null;
|
||||
|
||||
const updateSliderPosition = (percent) => {
|
||||
splitRatio = Math.max(0, Math.min(100, percent));
|
||||
slider.style.left = `${splitRatio}%`;
|
||||
if (mediaB) {
|
||||
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
|
||||
}
|
||||
};
|
||||
|
||||
const startDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const doDrag = (e) => {
|
||||
if (!isDragging) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const endDrag = () => { isDragging = false; };
|
||||
|
||||
wrapper.addEventListener("mousedown", startDrag);
|
||||
wrapper.addEventListener("touchstart", startDrag);
|
||||
|
||||
window.addEventListener("mousemove", doDrag);
|
||||
window.addEventListener("touchmove", doDrag);
|
||||
window.addEventListener("mouseup", endDrag);
|
||||
window.addEventListener("touchend", endDrag);
|
||||
|
||||
const destroy = () => {
|
||||
window.removeEventListener("mousemove", doDrag);
|
||||
window.removeEventListener("touchmove", doDrag);
|
||||
window.removeEventListener("mouseup", endDrag);
|
||||
window.removeEventListener("touchend", endDrag);
|
||||
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
videoSyncActive = false;
|
||||
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
|
||||
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
|
||||
|
||||
container.remove();
|
||||
activeComparisonViewer = null;
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
|
||||
const handleKeys = (e) => {
|
||||
if (e.key === "Escape") destroy();
|
||||
if (e.key === " " && mediaB) {
|
||||
e.preventDefault();
|
||||
updateSliderPosition(splitRatio > 50 ? 0 : 100);
|
||||
}
|
||||
};
|
||||
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
targetContainer.appendChild(container);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
}
|
||||
|
||||
return {
|
||||
loadTarget(targetSrc, isShiftClick) {
|
||||
const isTargetVideo = isVideoFormat(targetSrc);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
|
||||
}
|
||||
@@ -2,39 +2,38 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
|
||||
import { isVideoFormat, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { showFullscreenPreview } from "./comparison.js";
|
||||
|
||||
// DI Hook for cyclic imports
|
||||
export let syncQueueFn = async () => {};
|
||||
export function setSyncQueue(fn) { syncQueueFn = fn; }
|
||||
|
||||
export function showFullscreenPreview(imgSrcs) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
const overlay = document.createElement("div");
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
|
||||
background: "rgba(0,0,0,0.9)", zIndex: "10000", display: "flex",
|
||||
flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "zoom-out"
|
||||
});
|
||||
|
||||
const content = document.createElement("div");
|
||||
Object.assign(content.style, { maxWidth: "90%", maxHeight: "85%", display: "flex", justifyContent: "center" });
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
imgSrcs.forEach(src => {
|
||||
if (isVideoFormat(src)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = src; video.autoplay = true; video.controls = true; video.loop = true;
|
||||
video.style.maxWidth = "100%"; video.style.maxHeight = "100%";
|
||||
content.appendChild(video);
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = src; img.style.maxWidth = "100%"; img.style.maxHeight = "100%"; img.style.objectFit = "contain";
|
||||
content.appendChild(img);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
overlay.appendChild(content);
|
||||
overlay.onclick = () => overlay.remove();
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateSidebarBadge = (count) => {
|
||||
@@ -245,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;
|
||||
@@ -282,133 +282,98 @@ 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 (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;
|
||||
// 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");
|
||||
Object.assign(thumb.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element"
|
||||
});
|
||||
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;
|
||||
|
||||
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 = `
|
||||
<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);
|
||||
mediaEl.muted = true;
|
||||
mediaEl.playsInline = true;
|
||||
mediaEl.preload = "metadata";
|
||||
mediaEl.loop = true;
|
||||
mediaEl.setAttribute("draggable", "false");
|
||||
|
||||
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";
|
||||
}
|
||||
mediaEl.onloadedmetadata = () => {
|
||||
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
};
|
||||
thumb.src = src;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
|
||||
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
|
||||
} else {
|
||||
mediaEl.setAttribute("draggable", "true");
|
||||
mediaEl.onload = () => {
|
||||
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
};
|
||||
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
|
||||
const dragStartHandler = (e) => {
|
||||
State.currentDraggedImgData = { ...img, workflow: state.workflow };
|
||||
@@ -419,81 +384,97 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
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";
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
mediaEl.addEventListener("dragstart", dragStartHandler);
|
||||
mediaEl._currentDragStart = dragStartHandler;
|
||||
}
|
||||
|
||||
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
|
||||
// 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;
|
||||
@@ -507,11 +488,12 @@ const handleGlobalClick = (e) => {
|
||||
}
|
||||
const sidebar = State.sidebarContainer;
|
||||
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 10000"]');
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"]');
|
||||
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
State.activeSubmenuBatchImages = null;
|
||||
resetAllCardHoverStates(); // Reset stale overlays when clicking away
|
||||
renderDOM();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
@@ -530,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",
|
||||
@@ -541,7 +523,9 @@ export function renderDOM() {
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)"
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: "20",
|
||||
transform: "translateZ(0)" // Forces a GPU compositor stacking context to prevent video clipping
|
||||
});
|
||||
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"; };
|
||||
@@ -555,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";
|
||||
@@ -580,13 +564,14 @@ export function renderDOM() {
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
timerEl.textContent = `Image ${index + 1}/${batchInfo.images.length}`;
|
||||
timerEl.style.display = "block";
|
||||
timerEl.style.transform = "translateZ(0)";
|
||||
|
||||
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"
|
||||
display: "none", transform: "translateZ(0)"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
@@ -607,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";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -633,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);
|
||||
@@ -657,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";
|
||||
@@ -684,13 +700,14 @@ export function renderDOM() {
|
||||
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";
|
||||
timerEl.style.transform = "translateZ(0)";
|
||||
|
||||
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"
|
||||
display: "none", transform: "translateZ(0)"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
@@ -711,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";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -740,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";
|
||||
@@ -810,44 +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"
|
||||
// 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) {
|
||||
@@ -859,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) => {
|
||||
@@ -876,6 +937,7 @@ export function renderDOM() {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
});
|
||||
cardObj.element.id = `card-${state.pid}`; // set ID helper
|
||||
cardElements.set(state.pid, cardObj);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export const isVideoFormat = (url) => {
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
};
|
||||
|
||||
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
|
||||
export function findImagesInOutputs(outputs) {
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
@@ -13,6 +14,8 @@ export function findImagesInOutputs(outputs) {
|
||||
val.forEach(item => {
|
||||
if (item && typeof item === 'object' && item.filename) list.push(item);
|
||||
});
|
||||
} else if (val && typeof val === 'object' && val.filename) {
|
||||
list.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user