Compare commits

..
14 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
m0rtus59 c49859a75b Update README to improve feature announcement order
Reordered new feature announcements in the README for clarity.
2026-07-18 17:26:41 +05:00
M0rtus59 08454ff7db Added preview compare slider 2026-07-18 17:22:54 +05:00
M0rtus59 2fa512912e Improved work with batch output and intermediate outputs 2026-07-18 16:07:14 +05:00
M0rtus59 65900536ce Further optimizations 2026-07-18 15:05:41 +05:00
M0rtus59 7dbde17bcd Minor optimizations 2026-07-18 14:11:04 +05:00
M0rtus59 a63c7d2afd Optimize properties fix with MutationObserver, hide 'Graph' button toggle 2026-07-18 10:10:10 +05:00
m0rtus59 3ccd3b6a7c Update README with new features and improvements
Added new features including a compact topbar layout and side panel button toggles.
2026-07-15 23:12:50 +05:00
m0rtus59 735cac9c97 New unified compact topbar with controls and workflow tabs. New sidebar button toggles. Minor queue UI fixes 2026-07-15 22:32:44 +05:00
m0rtus59 a9575f1a1c fixed label placement 2026-07-12 10:02:22 +05:00
10 changed files with 1684 additions and 218 deletions
+4 -1
View File
@@ -11,8 +11,11 @@ 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.
* 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="287" height="685" alt="image" src="https://github.com/user-attachments/assets/ab414a37-d585-4bcb-aade-cf20ab5b24d8" />
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
## 🛠️ Installation
+358
View File
@@ -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]);
}
+66 -56
View File
@@ -1,5 +1,4 @@
import { app } from "/scripts/app.js";
import { State } from "./state.js";
async function uploadDroppedImageToInput(imageObj) {
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
@@ -22,72 +21,83 @@ async function uploadDroppedImageToInput(imageObj) {
export function setupDragAndDrop() {
document.addEventListener("dragover", (e) => {
if (State.currentDraggedImgData) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("drop", async (e) => {
if (!State.currentDraggedImgData) return;
const canvas = app.canvas;
if (canvas && canvas.graph) {
let targetNode = null;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
} else {
const rect = canvas.canvas.getBoundingClientRect();
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
}
if (!canvas || !canvas.graph) return;
let droppedOnImageNode = false;
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
if (State.currentDraggedImgData.filename) {
// 1. Check for standard workflow JSON data drop
const jsonStr = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
if (jsonStr) {
try {
const workflow = JSON.parse(jsonStr);
if (workflow && workflow.nodes) {
e.preventDefault();
e.stopPropagation();
droppedOnImageNode = true;
const widget = targetNode.widgets.find(w => w.name === "image");
if (widget) {
const newFilename = await uploadDroppedImageToInput(State.currentDraggedImgData);
if (newFilename) {
widget.value = newFilename;
if (widget.callback) widget.callback(widget.value);
targetNode.imgs = null;
app.graph.setDirtyCanvas(true, true);
if (app.loadGraphData) {
app.loadGraphData(workflow);
} else if (app.handleFile) {
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
await app.handleFile(file);
}
return;
}
} catch (err) {}
}
// 2. Check for native image URL drop
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
if (url) {
try {
const urlObj = new URL(url, window.location.origin);
if (urlObj.pathname === "/view") {
const filename = urlObj.searchParams.get("filename");
const type = urlObj.searchParams.get("type") || "output";
const subfolder = urlObj.searchParams.get("subfolder") || "";
if (filename) {
e.preventDefault();
e.stopPropagation();
let targetNode = null;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
} else {
const rect = canvas.canvas.getBoundingClientRect();
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
}
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
// Dropped on a node with image input: Copy/Upload image to inputs
const widget = targetNode.widgets.find(w => w.name === "image");
if (widget) {
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
if (newFilename) {
widget.value = newFilename;
if (widget.callback) widget.callback(widget.value);
targetNode.imgs = null;
app.graph.setDirtyCanvas(true, true);
}
}
} else {
// Dropped on empty canvas: Load workflow natively
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
const res = await fetch(src);
const blob = await res.blob();
const file = new File([blob], filename || "workflow.png", { type: blob.type });
if (app.handleFile) await app.handleFile(file);
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
}
}
}
}
if (!droppedOnImageNode) {
e.preventDefault();
e.stopPropagation();
try {
if (State.currentDraggedImgData.workflow) {
if (app.loadGraphData) {
app.loadGraphData(State.currentDraggedImgData.workflow);
} else if (app.handleFile) {
const jsonStr = JSON.stringify(State.currentDraggedImgData.workflow);
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
await app.handleFile(file);
}
} else if (State.currentDraggedImgData.filename) {
const src = State.currentDraggedImgData.url || `/view?filename=${encodeURIComponent(State.currentDraggedImgData.filename)}&type=${State.currentDraggedImgData.type || 'output'}&subfolder=${encodeURIComponent(State.currentDraggedImgData.subfolder || '')}`;
const res = await fetch(src);
const blob = await res.blob();
const file = new File([blob], State.currentDraggedImgData.filename || "workflow.png", { type: blob.type });
if (app.handleFile) await app.handleFile(file);
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
}
} catch (err) {
console.error("Comfy Sidebar: Failed to synthesize workflow file drop:", err);
}
} catch (err) {
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
}
}
State.currentDraggedImgData = null;
}, true);
}
+408
View File
@@ -0,0 +1,408 @@
// layout.js - Fully Fixed & Renamed to 'Hide Junk'
import { app } from "/scripts/app.js";
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
position: fixed !important;
top: -5px !important;
right: 4px !important;
left: auto !important;
transform: none !important;
z-index: 1010 !important;
/* Nuke the background rectangle, border, and shadows */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
}
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 150px !important;
padding-right: 580px !important;
}
/* Medium desktop screens (between 1200px and 1599px width) */
@media (max-width: 1599px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 100px !important;
padding-right: 420px !important;
}
}
/* Standard laptops / small monitors (between 900px and 1199px width) */
@media (max-width: 1199px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 40px !important;
padding-right: 280px !important;
}
}
/* Very narrow or heavily zoomed displays (< 900px width) */
@media (max-width: 899px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 10px !important;
padding-right: 180px !important;
}
}
`;
export function applyClassicLayout(enable) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
} else {
if (styleEl) styleEl.remove();
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
/* --- Properties Panel Toggle Corrections --- */
let savedButtonData = null;
let domObserver = null;
let syncScheduled = false;
function findOriginalButton() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
if (icon) {
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return btn;
}
}
return null;
}
function findTopbarContainer() {
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalButton();
if (orig && orig.parentNode) return orig.parentNode;
const buttons = document.querySelectorAll("button, .p-button");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("run") || title.includes("queue") || title.includes("manager")) {
if (btn.parentNode) return btn.parentNode;
}
}
return null;
}
function isPropertiesPanelOpen() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
return !!(icon && icon.className.includes("lucide--panel-left"));
}
function findActiveQueueIndicator() {
const container = findTopbarContainer();
if (!container) return null;
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const text = (btn.textContent || "").toLowerCase();
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
return btn;
}
}
return null;
}
function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
// Direct mapping from tab settings to their unique class-based HTML icon tags
const tabIconSelectors = {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"NodesMap": '[class*="pi-sitemap"]',
"Templates": '[class*="comfy--template"]'
};
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
// Read from renamed "Hide Junk" path
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
const icon = sidebar.querySelector(selector);
if (icon) {
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
if (shouldHide) {
if (tabBtn.style.display !== "none") {
tabBtn.style.setProperty("display", "none", "important");
}
} else {
if (tabBtn.style.display === "none") {
tabBtn.style.removeProperty("display");
}
}
}
});
// Handle "Override Stock Job History Tab" using its renamed setting path
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
if (historyIcon) {
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
if (hideStockHistory) {
if (historyBtn.style.display !== "none") {
historyBtn.style.setProperty("display", "none", "important");
}
} else {
if (historyBtn.style.display === "none") {
historyBtn.style.removeProperty("display");
}
}
}
}
function findGraphButton() {
// 1. Target elements with Tailwind classes, but IGNORE those inside modal settings/sidebars
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
for (const el of elements) {
// Filter out dialogs, sidebar overlays, settings drawers, and modals
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
continue;
}
return el;
}
// 2. Same fallback logic, excluding modals
const fallbacks = document.querySelectorAll('button, .bg-secondary-background');
for (const el of fallbacks) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
continue;
}
const text = (el.textContent || el.getAttribute("title") || "").toLowerCase();
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
return el;
}
}
return null;
}
export function syncClassicLayout() {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
// 1. Handle "Hide Graph Button" Toggle under the renamed "Hide Junk" path
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
if (graphBtn.style.display !== "none") {
graphBtn.style.setProperty("display", "none", "important");
}
} else {
if (graphBtn.style.display === "none") {
graphBtn.style.removeProperty("display");
}
}
}
// 2. Exit early and clean up overrides if Classic Topbar layout is off
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalButton();
if (originalBtn && originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) {
customBtn.remove();
}
const indicator = findActiveQueueIndicator();
if (indicator && indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
updateSidebarTabsVisibility();
return;
}
// 3. Handle Active Layout elements synchronization
const originalBtn = findOriginalButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
tagName: originalBtn.tagName,
attributes: Array.from(originalBtn.attributes).map(attr => ({
name: attr.name,
value: attr.value
}))
};
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = savedButtonData.innerHTML;
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
Object.assign(customBtn.style, {
position: "relative",
zIndex: "1000",
margin: "0",
boxSizing: "border-box",
opacity: "1",
pointerEvents: "auto",
display: "inline-flex"
});
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalButton();
if (nativeBtn) nativeBtn.click();
});
container.appendChild(customBtn);
}
if (customBtn) {
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
// 4. Handle "0 active" indicator visibility based on history tab settings
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const indicator = findActiveQueueIndicator();
if (indicator) {
if (hideQueueIndicator) {
if (indicator.style.display !== "none") {
indicator.style.setProperty("display", "none", "important");
}
} else {
if (indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
}
}
updateSidebarTabsVisibility();
}
export function setupPropertiesPanelToggleFix() {
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
const style = document.createElement("style");
style.id = "comfy-sidebar-layout-fix-styles";
style.textContent = `
.comfy-sidebar-hide-original-properties-btn {
display: none !important;
}
.comfy-sidebar-custom-properties-toggle {
order: 99999 !important;
margin-left: -8px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
order: 99999 !important;
margin-left: 0px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
`;
document.head.appendChild(style);
}
if (domObserver) {
domObserver.disconnect();
}
syncClassicLayout();
domObserver = new MutationObserver((mutations) => {
let shouldSync = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
// Ensure mutation is on an actual Element node (nodeType 1) and not just text node updates
const hasElementMutation = Array.from(mutation.addedNodes).some(node => node.nodeType === 1) ||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1);
if (!hasElementMutation) continue;
// Ignore mutations on our own components to avoid feedback loops
const isOurNode = Array.from(mutation.addedNodes).some(node =>
node.classList?.contains?.("comfy-sidebar-custom-properties-toggle") ||
node.id === "comfy-sidebar-layout-fix-styles"
);
if (isOurNode) continue;
shouldSync = true;
break;
}
}
if (shouldSync && !syncScheduled) {
syncScheduled = true;
requestAnimationFrame(() => {
syncClassicLayout();
syncScheduled = false;
});
}
});
domObserver.observe(document.body, {
childList: true,
subtree: true
});
}
+18 -2
View File
@@ -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);
}
@@ -122,6 +123,7 @@ export function setupApiListeners() {
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
renderDOMFn(); // Render immediately to prevent pop-in delay!
syncQueue();
});
@@ -162,9 +164,22 @@ export function setupApiListeners() {
api.addEventListener("executed", (e) => {
if (promptStates.has(e.detail.prompt_id)) {
const st = promptStates.get(e.detail.prompt_id);
const finalImgs = findImagesInOutputs({ output: e.detail.output });
if (finalImgs.length > 0) promptStates.get(e.detail.prompt_id).images = finalImgs;
promptStates.get(e.detail.prompt_id).texts = findTextsInOutputs({ output: e.detail.output });
if (finalImgs.length > 0) {
// Overwrite with latest images to avoid accumulation across sequential nodes
st.images = finalImgs;
}
const finalTexts = findTextsInOutputs({ output: e.detail.output });
if (finalTexts.length > 0) {
// 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();
}
});
@@ -206,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
});
+64 -3
View File
@@ -1,26 +1,87 @@
// sidebar.js - Fully Fixed Real-Time UI Sync (With setTimeout deferred updates)
import { app } from "/scripts/app.js";
import { injectStyles } from "./styles.js";
import { setupDragAndDrop } from "./dragdrop.js";
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
init() {
// Standard Comfy Queue Settings
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Override Stock Job History Tab", name: "Replaces the stock Job History sidebar with Comfy Queue", type: "boolean", defaultValue: false });
// Settings toggle for Auto-clear Interrupted runs
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
// Add settings to hide individual sidebar tabs under the "Hide Junk" sub-category
// We use setTimeout to let Comfy finish writing the setting value before reading it
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "NodesMap", "Templates"];
sidebarTabs.forEach(tab => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: `Hide Tab: ${tab}`,
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
});
// Group the Stock History Tab override into "Hide Junk"
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
name: "Replaces the stock Job History sidebar with Comfy Queue",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
// Add the toggle setting for Graph Button under "Hide Junk"
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Graph Button",
name: "Hide floating 'Graph' (Workflow/Node Map) button",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
// Add the toggle setting for Classic / Comfy Layout
app.ui.settings.addSetting({
id: "Comfy Sidebar.Comfy Layout",
name: "Places the controls and the open workflow tabs on a single unified top bar",
type: "boolean",
defaultValue: false,
onChange: (value) => {
applyClassicLayout(value);
}
});
},
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
// Apply saved layout configuration on startup
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
applyClassicLayout(isClassicLayoutEnabled);
// Run properties panel toggle correction
setupPropertiesPanelToggleFix();
// Safely resolve circular dependencies using Dependency Injection Hooks
setSyncQueue(syncQueue);
setUIDependencies(renderDOM, updateSidebarBadge);
+4 -2
View File
@@ -3,12 +3,13 @@ export const cardElements = new Map();
// Global shared variables
export const State = {
currentDraggedImgData: null,
currentSearchQuery: "",
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) {
@@ -39,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,
+35 -4
View File
@@ -1,3 +1,4 @@
// styles.js
export function injectStyles() {
const style = document.createElement("style");
style.textContent = `
@@ -6,24 +7,54 @@ export function injectStyles() {
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
transition: border-color 0.2s, background-color 0.2s;
border: 2px solid var(--border-color, #333);
color: var(--comfy-input-color, var(--fg-color, #eee));
}
.comfy-sidebar-card:hover { border-color: var(--p-primary-color, var(--primary-color, #555)) !important; }
.comfy-sidebar-card:hover {
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
transition: border-color 0.2s, background-color 0.2s;
}
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
.comfy-sidebar-card.pending .pi-times { display: none !important; }
.comfy-sidebar-card.pending:hover .pi-times { display: flex !important; }
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
display: none !important;
}
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
display: flex !important;
}
.comfy-sidebar-card-timer {
position: absolute; top: 6px; left: 8px; font-size: 10px;
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
}
/* --- Custom Queue Cancel Button to Mimic ComfyUI Native Destructive Button --- */
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important; /* Native deep red */
color: #e2e8f0 !important; /* Native slate white icon */
font-size: 11px !important; /* Muted native cross size */
border-radius: 6px !important; /* Native corner rounding */
cursor: pointer !important;
transition: background-color 0.15s ease, color 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
background-color: #991b1b !important; /* Slightly brighter red on hover */
color: #ffffff !important;
}
`;
document.head.appendChild(style);
}
+711 -150
View File
@@ -1,40 +1,39 @@
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";
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) => {
@@ -85,7 +84,7 @@ export const findStandardQueueButton = () => {
};
export const applySidebarOverride = () => {
const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Override Stock Job History Tab") ?? false;
const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
@@ -238,6 +237,20 @@ 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;
resetAllCardHoverStates(); // Instantly wipe stale hover indicators on exit click
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,236 @@ 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;
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);
// 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);
}
// Find or create the media tag in-place
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
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);
}
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) {
mediaEl.muted = true;
mediaEl.playsInline = true;
mediaEl.preload = "metadata";
mediaEl.loop = true;
mediaEl.setAttribute("draggable", "false");
mediaEl.onloadedmetadata = () => {
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
};
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 };
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){}
}
};
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);
}
}
// 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;
// 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: 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;
}
};
let renderTimeout = null;
export function renderDOM() {
if (renderTimeout) cancelAnimationFrame(renderTimeout);
@@ -265,6 +508,304 @@ 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 styleActionBtn = (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)",
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"; };
};
// --- 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; resetAllCardHoverStates(); 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";
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", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
const p = document.createElement("div");
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";
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, 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";
});
}
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();
};
// 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);
});
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; resetAllCardHoverStates(); 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";
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", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
const p = document.createElement("div");
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";
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, 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";
});
}
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();
});
};
// 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";
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));
@@ -282,10 +823,17 @@ export function renderDOM() {
if (!cardObj) {
const card = document.createElement("div");
const timerEl = document.createElement("div"); timerEl.className = "comfy-sidebar-card-timer";
const cancelX = document.createElement("span"); cancelX.className = "pi pi-times";
Object.assign(cancelX.style, { position: "absolute", top: "4px", right: "4px", color: "#dc3545", cursor: "pointer", fontSize: "20px", display: "none", zIndex: "10", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", transition: "color 0.2s" });
const cancelX = document.createElement("span");
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
Object.assign(cancelX.style, {
position: "absolute",
top: "4px",
right: "4px",
display: "none",
zIndex: "10"
});
const sBadge = document.createElement("div");
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none" });
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none", zIndex: "10" });
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, {
@@ -296,38 +844,112 @@ 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" });
// 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"; 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);
// 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"
});
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);
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, 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"; });
card.addEventListener("mouseleave", () => cardObj.hoverPanel.style.display = "none");
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) {
cardObj.leftHoverBtn.style.display = "inline-flex";
} else {
cardObj.leftHoverBtn.style.display = "none";
}
}
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
card.addEventListener("dragstart", (e) => {
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
if (state.images && state.images[0]) {
State.currentDraggedImgData = { ...state.images[0], workflow: state.workflow };
const img = state.images[0];
const fileUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
try { e.dataTransfer.setData("DownloadURL", `image/png:${img.filename || "output.png"}:${fileUrl}`); e.dataTransfer.setData("text/uri-list", fileUrl); e.dataTransfer.setData("text/plain", fileUrl); e.dataTransfer.items.add(new File([""], img.filename || "output.png", { type: "image/png" })); } catch (err) {}
} else if (state.workflow) {
State.currentDraggedImgData = { workflow: state.workflow };
// Custom dragstart is only used for JSON-only workflows to supply a local DownloadURL.
// For image cards, dragging is handled natively by the browser on the child <img> tag.
if (state.workflow && (!state.images || state.images.length === 0)) {
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
const jsonStr = JSON.stringify(state.workflow, null, 2);
try { e.dataTransfer.setData("DownloadURL", `application/json:workflow_${state.pid}.json:data:application/json;base64,` + btoa(unescape(encodeURIComponent(jsonStr)))); e.dataTransfer.setData("text/plain", jsonStr); e.dataTransfer.setData("application/json", jsonStr); } catch (err) {}
try {
e.dataTransfer.setData("DownloadURL", `application/json:workflow_${state.pid}.json:data:application/json;base64,` + btoa(unescape(encodeURIComponent(jsonStr))));
e.dataTransfer.setData("text/plain", jsonStr);
e.dataTransfer.setData("application/json", jsonStr);
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
}
e.dataTransfer.effectAllowed = "copy";
});
cardObj.element.id = `card-${state.pid}`; // set ID helper
cardElements.set(state.pid, cardObj);
}
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
cardObj.element.setAttribute("draggable", "true");
// Native drag isolation: completely remove draggable flags on image containers.
// This exposes the child <img> tag directly to Chrome's native filesystem drag APIs.
if (state.images && state.images.length > 0) {
cardObj.element.removeAttribute("draggable");
} else {
cardObj.element.setAttribute("draggable", "true");
}
if (state.status === "active") {
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
@@ -344,28 +966,58 @@ export function renderDOM() {
if (state.status === "pending" && !showPendingSummary) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => { ev.stopPropagation(); await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); await syncQueueFn(); };
} else cardObj.cancelBtn.style.display = "none";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
await syncQueueFn();
};
} else if (state.status === "active") {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
await api.interrupt(); // Natively interrupts current execution
await syncQueueFn();
};
} else {
cardObj.cancelBtn.style.display = "none";
}
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";
let deleteTimeout = null, isDeletePending = false;
const resetDeleteBtn = () => { isDeletePending = false; Object.assign(cardObj.btnDel.style, { color: "#dc3545", background: "none", padding: "0" }); cardObj.btnDel.title = "Delete Element from History"; if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } };
const resetDeleteBtn = () => {
isDeletePending = false;
Object.assign(cardObj.btnDel.style, {
color: "#e2e8f0",
backgroundColor: "rgba(0, 0, 0, 0.75)"
});
cardObj.btnDel.title = "Delete Card";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
cardObj.btnDel.onclick = async (ev) => {
ev.stopPropagation();
if (!isDeletePending) {
isDeletePending = true; Object.assign(cardObj.btnDel.style, { color: "#fff", background: "#dc3545", borderRadius: "4px", padding: "2px" }); cardObj.btnDel.title = "Click again to confirm deletion";
isDeletePending = true;
Object.assign(cardObj.btnDel.style, {
color: "#fff",
backgroundColor: "#dc3545" // Changes to solid red on warning confirmation
});
cardObj.btnDel.title = "Click again to confirm deletion";
deleteTimeout = setTimeout(resetDeleteBtn, 1000);
} else {
resetDeleteBtn(); promptStates.delete(state.pid); await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); renderDOM();
resetDeleteBtn();
promptStates.delete(state.pid);
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
renderDOM();
}
};
@@ -375,104 +1027,13 @@ 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 : `/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 : `/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 : `/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");
Object.assign(thumb.style, { width: "100%", borderRadius: "2px", display: "block", cursor: "zoom-in" });
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 = `
<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);
// 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"; }
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 : `/view?filename=${encodeURIComponent(i.filename)}&type=${i.type}&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;
}
if (state.images.length === 0) {
// Correctly display accumulated texts (like workflow prompts) while running as requested
if (state.texts && state.texts.length > 0) {
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left" });
} else {
+16
View File
@@ -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);
}
}
}
@@ -45,6 +48,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();