Compare commits

...
10 Commits
Author SHA1 Message Date
m0rtus59 f6e76b1789 Fixed layout for user avatar and new extension manager 2026-07-20 20:20:34 +05:00
m0rtus59 0e648a9fa9 intermediate step cards display dimensions 2026-07-19 16:36:04 +05:00
M0rtus59 684726789f Show intermediate results on cancelled runs if any. Improved drag&drop logic 2026-07-19 14:22:59 +05:00
M0rtus59 c2c82065a7 Drag&drop for unfinished tasks hooks up the workflow JSON. Updated readme 2026-07-19 12:26:17 +05:00
M0rtus59 5fde92b51a Added 'Ignore in queue' toggle 2026-07-19 11:52:29 +05:00
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
9 changed files with 790 additions and 393 deletions
+5 -2
View File
@@ -13,7 +13,8 @@ A lightweight, high-performance ComfyUI extension that replaces the cluttered st
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
* A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
* Toggles to hide buttons you don't need from the side panel.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then click another to load it alongside the first one for pixel by pixel comparison.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then `Shift+Click` another to load it alongside the first one for pixel by pixel comparison.
* **[NEW]** Prevent selected nodes from showing the output in the queue bar and intermediate results. `Ctrl+Q` to toggle.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
@@ -32,8 +33,10 @@ Restart your ComfyUI server and refresh the browser tab.
## ⚙️ Configuration
Additional tweaks are seamlessly integrated into the standard ComfyUI Settings panel. From there, you can adjust thresholds or fully replace the stock V2 panel by enabling options like **"Override Stock Job History Tab"**.
See additional tweaks in ComfyUI settings menu > Comfy Sidebar. From there, you can adjust the layout or fully replace the stock V2 panel by enabling options like **"Comfy Layout"** and **"Replace the stock Job History sidebar with Comfy Queue"**.
## ⌨️ Shortcuts
* **`Q`**: Instantly toggles the sidebar panel open or closed. Safely ignores inputs if you are currently typing inside a text field, input area, or combo box.
* **`Ctrl+Q on a selected node`**: Sets the node to be ignored in the sidebar panel results.
* **`Click`** on a card to enter preview. **`Shift+Click`** on another card in preview to enter compare mode.
+58 -41
View File
@@ -5,6 +5,9 @@ let activeComparisonViewer = null;
let videoSyncActive = false;
let syncAnimationFrameId = null;
// Track the global keydown reference across instances defensively
let globalKeydownHandler = null;
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
@@ -13,7 +16,7 @@ const createMediaElement = (src, muted = false) => {
el.playsInline = true;
el.autoplay = true;
el.loop = true;
el.controls = false; // Disable native controls to prevent layout shifts
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
@@ -23,7 +26,6 @@ const createMediaElement = (src, muted = false) => {
return el;
};
// Video Playback Manager: Binds timeline progress bar, timeline scrubbing, and play/pause triggers
const setupVideoPlayback = (vid, container) => {
if (!vid) return;
@@ -33,10 +35,9 @@ const setupVideoPlayback = (vid, container) => {
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
// Synced Scrubber Progress Bar Overlay
const controlBar = document.createElement("div");
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
position: "absolute", bottom: "16px", left: "50%",
display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px",
@@ -102,7 +103,6 @@ const setupVideoPlayback = (vid, container) => {
const syncLoop = () => {
if (!videoSyncActive) return;
// Update progress & labels
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
@@ -115,9 +115,7 @@ const setupVideoPlayback = (vid, container) => {
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
// Dynamically match play button in case video loops
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
requestAnimationFrame(syncLoop);
@@ -133,14 +131,16 @@ const setupVideoPlayback = (vid, container) => {
};
function createComparisonViewer(baseSrc) {
const isBaseVideo = isVideoFormat(baseSrc);
// DEFENSIVE CHECK: Force-remove any globally orphaned keydown handlers before binding a new one
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
// Mount the viewer overlay container directly inside ComfyUI's canvas parent.
// This allows menus/sidebars outside the canvas area to naturally render on top.
const isBaseVideo = isVideoFormat(baseSrc);
const canvasEl = document.querySelector("#graph-canvas, canvas");
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
// Ensure the canvas container is positioned relatively so our overlay stretches accurately
if (targetContainer && targetContainer !== document.body) {
targetContainer.style.position = "relative";
}
@@ -151,7 +151,7 @@ function createComparisonViewer(baseSrc) {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
zIndex: "10", // Above base LGraph grid but strictly below other UI components
zIndex: "10",
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
});
@@ -164,11 +164,10 @@ function createComparisonViewer(baseSrc) {
const infoText = document.createElement("span");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Reference loaded. Click another card image in the sidebar to compare.";
: "Reference loaded. Click to replace | Shift+Click another card image to compare.";
header.appendChild(infoText);
container.appendChild(header);
// Close button
const closeBtn = document.createElement("span");
closeBtn.className = "pi pi-times";
closeBtn.title = "Close Comparison (Esc)";
@@ -180,7 +179,6 @@ function createComparisonViewer(baseSrc) {
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
container.appendChild(closeBtn);
// Workspace wrapper holding both elements aligned pixel-perfectly
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
@@ -188,21 +186,30 @@ function createComparisonViewer(baseSrc) {
});
container.appendChild(wrapper);
// Close strictly on clicking empty background space of the overlay backdrop
// Muted bottom tooltip helper
const hintPrompt = document.createElement("div");
Object.assign(hintPrompt.style, {
position: "absolute", bottom: "16px", zIndex: "30",
color: "#555", fontSize: "10px", fontFamily: "sans-serif",
pointerEvents: "none"
});
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#777;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
if (isBaseVideo) {
hintPrompt.style.display = "none";
}
container.appendChild(hintPrompt);
container.onclick = (e) => {
if (e.target === container) {
destroy();
}
};
// Media A (Base reference - Left. Created unmuted to enable audio playback)
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
// Media B (Comparison target - Right)
let mediaB = null;
// Draggable Split Slider Handle
const slider = document.createElement("div");
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
@@ -223,7 +230,7 @@ function createComparisonViewer(baseSrc) {
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
let splitRatio = 50; // Percentage
let splitRatio = 50;
let isDragging = false;
let destroyVideoPlaybackFn = null;
@@ -231,7 +238,6 @@ function createComparisonViewer(baseSrc) {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
if (mediaB) {
// Symmetrical split comparison layout: Image A on Left, Image B on Right
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
}
};
@@ -241,7 +247,6 @@ function createComparisonViewer(baseSrc) {
e.stopPropagation();
isDragging = true;
// Symmetrical Click Teleportation: Snaps split-line directly to mouse coordinates
const rect = wrapper.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const percent = ((clientX - rect.left) / rect.width) * 100;
@@ -256,7 +261,6 @@ function createComparisonViewer(baseSrc) {
};
const endDrag = () => { isDragging = false; };
// Allows dragging/clicking from any region of the wrapper
wrapper.addEventListener("mousedown", startDrag);
wrapper.addEventListener("touchstart", startDrag);
@@ -270,7 +274,11 @@ function createComparisonViewer(baseSrc) {
window.removeEventListener("touchmove", doDrag);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
document.removeEventListener("keydown", handleKeys);
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
videoSyncActive = false;
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
@@ -289,53 +297,62 @@ function createComparisonViewer(baseSrc) {
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
};
document.addEventListener("keydown", handleKeys);
globalKeydownHandler = handleKeys;
document.addEventListener("keydown", globalKeydownHandler);
targetContainer.appendChild(container);
// Initialize single-video playbacks if the base output is an MP4
if (isBaseVideo) {
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
}
return {
loadTarget(targetSrc) {
loadTarget(targetSrc, isShiftClick) {
const isTargetVideo = isVideoFormat(targetSrc);
if (isBaseVideo) {
// If in Video Playback Mode: clicking another item replaces the player or opens image comparison
destroy();
showFullscreenPreview([targetSrc]);
showFullscreenPreview([targetSrc], isShiftClick);
return;
}
// If in Image Comparison Mode: ignore video targets ("images compare only to other images")
if (isTargetVideo) {
return;
}
if (mediaB) mediaB.remove();
if (isShiftClick) {
// Shift+Click: Sideload target image B for comparison split
if (mediaB) mediaB.remove();
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
wrapper.appendChild(mediaB);
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
wrapper.appendChild(mediaB);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Click other card images to update target | Esc to close.";
updateSliderPosition(50);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close.";
updateSliderPosition(50);
} else {
// Standard Click: Replace the main base reference image entirely
if (mediaB) {
mediaB.remove();
mediaB = null;
}
slider.style.display = "none";
mediaA.src = targetSrc;
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
}
}
};
}
export function showFullscreenPreview(imgSrcs) {
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
if (!imgSrcs || imgSrcs.length === 0) return;
// If the comparison overlay is already open, dynamically load the target
if (activeComparisonViewer) {
activeComparisonViewer.loadTarget(imgSrcs[0]);
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
return;
}
// Otherwise, open a fresh side-by-side comparison workspace
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
}
+10
View File
@@ -1,4 +1,5 @@
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 || '')}`;
@@ -26,6 +27,15 @@ export function setupDragAndDrop() {
});
document.addEventListener("drop", async (e) => {
// Cancel loading entirely and prevent bubbling if dropping inside the sidebar area
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
if (isSidebarDrop) {
e.preventDefault();
e.stopPropagation();
return;
}
const canvas = app.canvas;
if (!canvas || !canvas.graph) return;
+197 -117
View File
@@ -25,6 +25,46 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
align-items: center !important;
}
/* Style overrides for the new native Extensions button container when embedded in top actionbar */
.comfy-sidebar-extensions-override {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 8px 0 0 !important;
height: auto !important;
position: static !important;
order: -10 !important;
display: inline-flex !important;
align-items: center !important;
}
/* Scale down performance indicators (Crystools) uniformly without breaking their native styling */
#crysmonitor-monitors-root,
#crystools-monitors-root,
.crysmonitor-monitors-container {
zoom: 0.8 !important; /* Restored to original 0.8 scale for perfect topbar alignment */
display: inline-flex !important;
align-items: center !important;
}
/* Hide only the user account profile button and its PrimeVue components (initials/icons) */
img[alt*="User Avatar"],
img[alt*="user avatar"],
button:has(img[alt*="User Avatar"]),
[role="button"]:has(img[alt*="User Avatar"]),
button:has(img[alt*="user avatar"]),
[role="button"]:has(img[alt*="user avatar"]),
/* PrimeVue Avatar component class and attribute selectors */
.p-avatar,
[data-pc-name="avatar"],
button:has(.p-avatar),
[role="button"]:has(.p-avatar),
button:has([data-pc-name="avatar"]),
[role="button"]:has([data-pc-name="avatar"]) {
display: none !important;
}
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
.p-tabview-nav-content,
[class*="tabview"] {
@@ -60,11 +100,11 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
}
`;
export function applyClassicLayout(enable) {
export function applyClassicLayout(enable, updateSetting = false) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (app.ui && app.ui.settings) {
if (updateSetting && app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
@@ -78,7 +118,7 @@ export function applyClassicLayout(enable) {
} else {
if (styleEl) styleEl.remove();
if (app.ui && app.ui.settings) {
if (updateSetting && app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
@@ -102,7 +142,7 @@ function findOriginalButton() {
}
function findTopbarContainer() {
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalButton();
@@ -121,7 +161,9 @@ function findTopbarContainer() {
function isPropertiesPanelOpen() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
return !!(icon && icon.className.includes("lucide--panel-left"));
if (!icon) return false;
const classStr = icon.getAttribute("class") || "";
return classStr.includes("lucide--panel-left");
}
function findActiveQueueIndicator() {
@@ -140,6 +182,12 @@ function findActiveQueueIndicator() {
return null;
}
function findNativeExtensionsPanel() {
// Exclude the main actionbar-container itself to prevent infinite loop appending bugs
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
}
function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
@@ -218,121 +266,160 @@ function findGraphButton() {
}
export function syncClassicLayout() {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
// Temporarily disconnect observer to cleanly avoid infinite layout feedback loops during DOM rearrangement
if (domObserver) domObserver.disconnect();
// 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");
try {
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) {
// 2. Handle Native Extensions Button alignment in Classic Layout
const extensionsPanel = findNativeExtensionsPanel();
if (extensionsPanel) {
if (isClassicLayoutEnabled) {
const container = findTopbarContainer();
if (container && extensionsPanel.parentNode !== container) {
// Save original parent so we can restore it if the layout toggle is turned off
if (!extensionsPanel._originalParent) {
extensionsPanel._originalParent = extensionsPanel.parentNode;
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
}
container.appendChild(extensionsPanel);
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
}
} else {
// Restore original placement when classic layout is disabled
if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
}
}
}
// 3. 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;
}
// 4. Handle Active Layout elements synchronization
const originalBtn = findOriginalButton();
if (originalBtn && originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
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);
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) {
customBtn.remove();
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
// 5. 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 && indicator.style.display === "none") {
indicator.style.removeProperty("display");
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();
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");
}
} catch (err) {
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
} finally {
// Re-enable mutation monitoring once our changes have successfully updated in the DOM
if (domObserver) {
domObserver.observe(document.body, {
childList: true,
subtree: true
});
}
}
updateSidebarTabsVisibility();
}
export function setupPropertiesPanelToggleFix() {
@@ -369,27 +456,17 @@ export function setupPropertiesPanelToggleFix() {
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 (hasElementMutation) {
shouldSync = true;
break;
}
}
}
if (shouldSync && !syncScheduled) {
@@ -405,4 +482,7 @@ export function setupPropertiesPanelToggleFix() {
childList: true,
subtree: true
});
// Run first layout sync now that structural observing is active
syncClassicLayout();
}
+15 -11
View File
@@ -1,6 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage } from "./state.js";
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage, cardElements } from "./state.js";
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
export let renderDOMFn = () => {};
@@ -81,8 +81,8 @@ const concludeRun = async (pid, statusStr) => {
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);
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow);
st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow);
}
} catch (err) {}
pruneHistory(app);
@@ -165,12 +165,12 @@ 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 });
const finalImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
if (finalImgs.length > 0) {
// Overwrite with latest images to avoid accumulation across sequential nodes
st.images = finalImgs;
}
const finalTexts = findTextsInOutputs({ output: e.detail.output });
const finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
if (finalTexts.length > 0) {
// Overwrite with latest texts to avoid clearing previously set text outputs
st.texts = finalTexts;
@@ -203,26 +203,30 @@ export async function initSessionAndHistory() {
}
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
if (backendSessionId && backendSessionId === storedSessionId) {
loadStatesFromLocalStorage();
} else {
if (backendSessionId && backendSessionId !== storedSessionId) {
// Session changed (server restarted)! Clear cached memory.
promptStates.clear();
cardElements.clear();
localStorage.removeItem("comfy_sidebar_prompt_states");
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
} else if (!storedSessionId && backendSessionId) {
localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
}
const historyData = await api.getHistory();
const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b));
ids.forEach(id => {
if (promptStates.has(id)) return;
const images = findImagesInOutputs(historyData[id].outputs);
const texts = findTextsInOutputs(historyData[id].outputs);
const workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null;
const images = findImagesInOutputs(historyData[id].outputs, workflow);
const texts = findTextsInOutputs(historyData[id].outputs, workflow);
if (images.length === 0 && texts.length === 0) return;
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,
workflow: workflow,
progressText: "", timestamp: State.globalOrderCounter, rendered: true
});
});
+144 -7
View File
@@ -6,6 +6,48 @@ import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueu
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
// Track initial loading phase to guard against early auto-fired settings triggers
let isInitialized = false;
function toggleIgnoreActiveNode() {
const canvas = app.canvas;
if (!canvas) return;
let nodes = [];
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
nodes = Object.values(canvas.selected_nodes);
} else if (canvas.current_active_node) {
nodes = [canvas.current_active_node];
}
if (nodes.length === 0) return;
nodes.forEach(node => {
if (!node.properties) node.properties = {};
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
});
app.graph.setDirtyCanvas(true, true);
}
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
function findHeaderByText(parent, text) {
if (!text) return null;
const cleanText = text.replace(/^(🚫|👁️⃠)\s*/, "").trim();
// Grab all potential elements that could hold the title text
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
for (const el of elements) {
if (el.textContent.trim() === cleanText) {
// Ensure we are selecting a small title element, not the entire node wrapper
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
return el;
}
}
}
return null;
}
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
@@ -44,8 +86,8 @@ app.registerExtension({
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
}
});
// Add the toggle setting for Graph Button under "Hide Junk"
app.ui.settings.addSetting({
@@ -67,7 +109,10 @@ app.registerExtension({
type: "boolean",
defaultValue: false,
onChange: (value) => {
applyClassicLayout(value);
// Only update the native layout configuration if setup() initialization has fully concluded
if (isInitialized) {
applyClassicLayout(value, true);
}
}
});
},
@@ -75,9 +120,12 @@ app.registerExtension({
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
// Apply saved layout configuration on startup
// Apply saved layout configuration on startup (never force overwrite on launch)
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
applyClassicLayout(isClassicLayoutEnabled);
applyClassicLayout(isClassicLayoutEnabled, false);
// Finalize initialization to open write pathways for settings-panel adjustments
isInitialized = true;
// Run properties panel toggle correction
setupPropertiesPanelToggleFix();
@@ -103,14 +151,103 @@ app.registerExtension({
document.addEventListener("keydown", (e) => {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
if (e.key.toLowerCase() === "q") {
// Toggle Queue open/close (Q)
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
// Toggle Ignore Node in Queue (Ctrl + Q)
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.stopPropagation();
toggleIgnoreActiveNode();
}
}, true);
setInterval(() => applySidebarOverride(), 500);
setInterval(() => {
applySidebarOverride();
// Sync ignoreInQueue visual states across both canvas and Vue DOM modes dynamically
if (app.graph && app.graph._nodes) {
app.graph._nodes.forEach(node => {
if (!node.properties) node.properties = {};
const isIgnored = !!node.properties.ignoreInQueue;
// 1. Sweep away legacy title emoji residue to keep graph titles perfectly clean
if (node.title && (node.title.startsWith("🚫 ") || node.title.startsWith("👁️⃠ "))) {
node.title = node.title.replace(/^(🚫|👁️⃠)\s*/, "");
app.graph.setDirtyCanvas(true, true);
}
// 2. Classic LiteGraph Native Glowing Border Sync
if (isIgnored) {
if (node.boxcolor !== "#ff3333") {
node._oldBoxcolor = node.boxcolor || "";
node.boxcolor = "#ff3333";
app.graph.setDirtyCanvas(true, true);
}
} else {
if (node.boxcolor === "#ff3333") {
node.boxcolor = node._oldBoxcolor || "";
delete node._oldBoxcolor;
app.graph.setDirtyCanvas(true, true);
}
}
// 3. Nodes 2.0 DOM Sibling Support (Vue Mode)
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
if (nodeEl) {
// Dynamically locate the node header container by searching for the node title text element
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
if (titleTextNode) {
const headerEl = titleTextNode.parentNode;
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
if (isIgnored) {
if (!badge) {
badge = document.createElement("div");
badge.className = "comfy-sidebar-ignore-badge";
// Custom SVG crossed-out eye
badge.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
<line x1="2" y1="2" x2="22" y2="22"/>
</svg>
`;
// Flows natively inside the parent header block with automatic flex alignment
Object.assign(badge.style, {
marginLeft: "auto",
marginRight: "6px",
float: "right",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "inherit", // Inherits color of parent text (automatically morphs on highlight!)
pointerEvents: "none"
});
headerEl.appendChild(badge);
}
} else {
if (badge) {
badge.remove();
}
}
} else {
// Fallback cleanup if the title text element is temporarily unmounted
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
if (badge && !isIgnored) {
badge.remove();
}
}
}
});
}
}, 500);
// 6. Final Registration
app.extensionManager.registerSidebarTab({
+4 -1
View File
@@ -66,4 +66,7 @@ export function loadStatesFromLocalStorage() {
} catch (e) {
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
}
}
}
// Load instantly and synchronously on script import to prevent race conditions during early renders!
loadStatesFromLocalStorage();
+336 -210
View File
@@ -8,6 +8,34 @@ import { showFullscreenPreview } from "./comparison.js";
export let syncQueueFn = async () => {};
export function setSyncQueue(fn) { syncQueueFn = fn; }
// Resets any stale hover states when transitioning between submenus and the main queue
function resetAllCardHoverStates() {
for (const cardObj of cardElements.values()) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
}
}
// Traverses node execution metadata to resolve the exact node ID that generated a specific image
function findNodeIdForImage(state, img) {
if (!state || !state.nodeOutputs || !img) return null;
for (const nodeId in state.nodeOutputs) {
const out = state.nodeOutputs[nodeId];
// Safely check both gifs, images, or videos under this specific node
for (const key in out) {
const val = out[key];
if (Array.isArray(val)) {
if (val.some(i => i && typeof i === 'object' && i.filename === img.filename)) {
return nodeId;
}
} else if (val && typeof val === 'object' && val.filename === img.filename) {
return nodeId;
}
}
}
return null;
}
export const updateSidebarBadge = (count) => {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
@@ -174,7 +202,7 @@ export function setupSidebarUI() {
promptStates.delete(pid);
}
}
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clear: true }) }); } catch (err) {}
try { await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ clear: true }) }); } catch (err) {}
renderDOM();
});
@@ -216,6 +244,7 @@ export function setupSidebarUI() {
if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return;
State.activeSubmenuPromptId = null;
State.activeSubmenuBatchImages = null;
resetAllCardHoverStates(); // Instantly wipe stale hover indicators on exit click
renderDOM();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
@@ -253,28 +282,106 @@ 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"));
// 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);
}
if (matchesTagType) {
// --- IN-PLACE REUSE PATH ---
const mediaEl = currentMediaEl;
// Find or create the media tag in-place
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
// 1. Update interactive event listeners on reuse to bind correct closure variables
mediaEl.onclick = (ev) => {
ev.stopPropagation();
showFullscreenPreview([src]);
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";
}
};
// Bind dimensions hooks and event listeners EVERY run (for recycled elements & fast cache hits!)
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);
};
if (mediaEl.readyState >= 1) {
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);
}
if (!isVideo) {
// Re-bind native drag handlers with updated state values
const newDragStart = (e) => {
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
} else {
const isUnfinished = state.status && state.status !== "completed";
if (isUnfinished) {
mediaEl.setAttribute("draggable", "false");
mediaEl.style.cursor = "grab";
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
delete mediaEl._currentDragStart;
} else {
mediaEl.setAttribute("draggable", "true");
mediaEl.style.cursor = "zoom-in";
const dragStartHandler = (e) => {
State.currentDraggedImgData = { ...img, workflow: state.workflow };
try {
e.dataTransfer.setData("text/uri-list", src);
@@ -284,167 +391,107 @@ function renderCardImages(cardObj, state, keepAspect) {
e.stopPropagation();
};
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
mediaEl.addEventListener("dragstart", newDragStart);
mediaEl._currentDragStart = newDragStart;
mediaEl.addEventListener("dragstart", dragStartHandler);
mediaEl._currentDragStart = dragStartHandler;
}
// 2. Perform smooth in-memory source swapping for blob live previews
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
if (src.startsWith("blob:") && !isVideo) {
// Smooth preload trick: hold current preview until the new one is fully loaded
const tempImg = new Image();
tempImg.onload = () => {
const oldBlob = mediaEl._lastBlob;
mediaEl.src = src;
mediaEl._lastBlob = src;
if (keepAspect && tempImg.naturalWidth && tempImg.naturalHeight) {
mediaEl.style.aspectRatio = `${tempImg.naturalWidth} / ${tempImg.naturalHeight}`;
}
if (cardObj.dimEl && tempImg.naturalWidth && tempImg.naturalHeight) {
cardObj.dimEl.textContent = `${tempImg.naturalWidth}x${tempImg.naturalHeight}`;
cardObj.dimEl.style.display = "block";
}
if (oldBlob && oldBlob !== src) {
try { URL.revokeObjectURL(oldBlob); } catch(e){}
}
};
tempImg.src = src;
} else {
// Direct fast swap for standard server files
mediaEl.src = isVideo ? src + "#t=0.001" : src;
}
}
// 3. Update the pagination label if it exists
const label = cardObj.grid.querySelector(".comfy-sidebar-batch-label");
if (label) {
label.textContent = `${idx + 1}/${state.images.length}`;
}
return;
}
// --- REBUILD PATH (ONLY RUNS ON TYPE CHANGE OR FIRST RENDER) ---
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
// Explicit low stacking index combined with standard native webkit dragging force rules
Object.assign(thumb.style, {
width: "100%",
borderRadius: "2px",
display: "block",
cursor: "zoom-in",
webkitUserDrag: "element",
zIndex: "1"
});
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: "2", transition: "opacity 0.2s ease"
});
playIcon.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
</svg>
`;
wrapper.appendChild(playIcon);
thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
}
if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
// Keep native image tags naturally draggable
if (!isVideo) {
thumb.setAttribute("draggable", "true");
} else {
thumb.setAttribute("draggable", "false");
}
if (!isVideo) {
thumb.onload = () => { if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`; };
if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { thumb.src = src; thumb._lastBlob = src; }; tempImg.src = src; } else thumb.src = src;
} else {
thumb.onloadedmetadata = () => { if (keepAspect && thumb.videoWidth && thumb.videoHeight) thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`; };
thumb.src = src + "#t=0.001";
}
thumb.onclick = (ev) => {
ev.stopPropagation();
showFullscreenPreview([src]);
};
wrapper.insertBefore(thumb, wrapper.firstChild);
cardObj.firstImgElement = thumb;
// Overlay slick pagination controller bar if batch has multiple output images
if (state.images.length > 1) {
const navBar = document.createElement("div");
Object.assign(navBar.style, {
position: "absolute", bottom: "6px", left: "50%", transform: "translateX(-50%)",
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)" // Forces a GPU compositor stacking context to prevent video clipping
});
const prevBtn = document.createElement("span");
prevBtn.className = "pi pi-chevron-left";
prevBtn.style.cursor = "pointer";
prevBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
renderCardImages(cardObj, state, keepAspect);
mediaEl.onload = () => {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
};
if (mediaEl.complete && mediaEl.naturalWidth) {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
}
const label = document.createElement("span");
label.className = "comfy-sidebar-batch-label";
label.textContent = `${idx + 1}/${state.images.length}`;
label.style.cursor = "pointer";
label.title = "View all images in this batch";
// Open the dynamic batch explorer explorer view when the middle counter label is clicked
label.onclick = (ev) => {
ev.stopPropagation();
State.activeSubmenuBatchImages = {
pid: state.pid || cardObj.element.id.replace("card-", ""),
images: state.images,
workflow: state.workflow
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (playIcon) playIcon.remove();
}
// Smooth in-memory source swapping for blob live previews
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
if (src.startsWith("blob:") && !isVideo) {
const tempImg = new Image();
tempImg.onload = () => {
const oldBlob = mediaEl._lastBlob;
mediaEl.src = src;
mediaEl._lastBlob = src;
applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight);
if (oldBlob && oldBlob !== src) {
try { URL.revokeObjectURL(oldBlob); } catch(e){}
}
};
renderDOM();
};
const nextBtn = document.createElement("span");
nextBtn.className = "pi pi-chevron-right";
nextBtn.style.cursor = "pointer";
nextBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
renderCardImages(cardObj, state, keepAspect);
};
navBar.append(prevBtn, label, nextBtn);
wrapper.appendChild(navBar);
tempImg.src = src;
} else {
mediaEl.src = isVideo ? src + "#t=0.001" : src;
}
} else {
// If the source is already correct, make sure we still force dimensions calculations
if (isVideo) {
if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
} else {
if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
}
}
cardObj.grid.appendChild(wrapper);
// Sync batch pagination controller overlay
let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar");
if (state.images.length > 1) {
if (!navBar) {
navBar = document.createElement("div");
navBar.className = "comfy-sidebar-batch-navbar";
Object.assign(navBar.style, {
position: "absolute", bottom: "6px", left: "50%",
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)"
});
const prevBtn = document.createElement("span");
prevBtn.className = "pi pi-chevron-left";
prevBtn.style.cursor = "pointer";
prevBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
renderCardImages(cardObj, state, keepAspect);
};
const label = document.createElement("span");
label.className = "comfy-sidebar-batch-label";
label.textContent = `${idx + 1}/${state.images.length}`;
label.style.cursor = "pointer";
label.title = "View all images in this batch";
// Open the dynamic batch explorer explorer view when the middle counter label is clicked
label.onclick = (ev) => {
ev.stopPropagation();
State.activeSubmenuBatchImages = {
pid: state.pid || cardObj.element.id.replace("card-", ""),
images: state.images,
workflow: state.workflow
};
resetAllCardHoverStates(); // Prevent ghost hover handles
renderDOM();
};
const nextBtn = document.createElement("span");
nextBtn.className = "pi pi-chevron-right";
nextBtn.style.cursor = "pointer";
nextBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
renderCardImages(cardObj, state, keepAspect);
};
navBar.append(prevBtn, label, nextBtn);
wrapper.appendChild(navBar);
}
const label = navBar.querySelector(".comfy-sidebar-batch-label");
if (label) label.textContent = `${idx + 1}/${state.images.length}`;
} else {
if (navBar) navBar.remove();
}
}
let globalClickRegistered = false;
@@ -463,6 +510,7 @@ const handleGlobalClick = (e) => {
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
State.activeSubmenuPromptId = null;
State.activeSubmenuBatchImages = null;
resetAllCardHoverStates(); // Reset stale overlays when clicking away
renderDOM();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
@@ -481,7 +529,7 @@ export function renderDOM() {
const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
const styleRightBtn = (btn) => {
const styleActionBtn = (btn) => {
Object.assign(btn.style, {
display: "inline-flex",
alignItems: "center", justifyContent: "center",
@@ -508,7 +556,7 @@ export function renderDOM() {
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back to Queue";
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; renderDOM(); };
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; resetAllCardHoverStates(); renderDOM(); };
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
@@ -561,18 +609,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";
styleActionBtn(btnFocus);
leftHoverPanel.appendChild(btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
cardElements.set(cardId, cardObj);
card.addEventListener("mouseenter", () => {
cardObj.hoverPanel.style.display = "flex";
cardObj.leftHoverPanel.style.display = "flex";
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
@@ -587,6 +650,22 @@ export function renderDOM() {
a.click();
};
// Focus/center target node inside Batch Explorer Submenu
const nodeId = findNodeIdForImage(batchInfo, img);
if (nodeId) {
cardObj.btnFocus.style.display = "inline-flex";
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
const node = app.graph.getNodeById(Number(nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
};
} else {
cardObj.btnFocus.style.display = "none";
}
renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow }, keepAspect);
targetElements.push(cardObj.element);
@@ -611,7 +690,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";
@@ -621,7 +700,7 @@ export function renderDOM() {
globalClickRegistered = true;
}
const outputs = getRunOutputs(st.nodeOutputs);
const outputs = getRunOutputs(st.nodeOutputs, st.workflow);
const targetElements = [];
outputs.forEach((out) => {
@@ -666,18 +745,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";
styleActionBtn(btnFocus);
leftHoverPanel.appendChild(btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
cardElements.set(cardId, cardObj);
card.addEventListener("mouseenter", () => {
cardObj.hoverPanel.style.display = "flex";
cardObj.leftHoverPanel.style.display = "flex";
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
@@ -695,6 +789,16 @@ export function renderDOM() {
});
};
// Focus/center target node inside Workflow outputs Submenu
cardObj.btnFocus.onclick = (ev) => {
ev.stopPropagation();
const node = app.graph.getNodeById(Number(out.nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
};
renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow }, keepAspect);
} else {
cardObj.btnImg.style.display = "none";
@@ -765,48 +869,67 @@ export function renderDOM() {
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleRightBtn(btnImg);
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleRightBtn(btnJson);
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleRightBtn(btnDel);
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleActionBtn(btnImg);
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleActionBtn(btnJson);
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleActionBtn(btnDel);
// Bottom left Explorer Submenu Button
const leftHoverBtn = document.createElement("span");
leftHoverBtn.className = "pi pi-images comfy-sidebar-left-hover-btn";
leftHoverBtn.title = "View all intermediate outputs";
Object.assign(leftHoverBtn.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
alignItems: "center", justifyContent: "center",
width: "32px", height: "32px",
backgroundColor: "rgba(0, 0, 0, 0.75)",
color: "#e2e8f0",
fontSize: "14px",
borderRadius: "6px",
cursor: "pointer",
transition: "all 0.15s ease",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
zIndex: "20",
transform: "translateZ(0)"
// Absolute Vertical Left Actions Panel containing btnFocus and leftHoverBtn
const leftHoverPanel = document.createElement("div");
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
flexDirection: "column", gap: "4px", zIndex: "20"
});
leftHoverBtn.onmouseenter = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; leftHoverBtn.style.color = "#fff"; };
leftHoverBtn.onmouseleave = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; leftHoverBtn.style.color = "#e2e8f0"; };
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye";
btnFocus.title = "Show Node";
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";
// Show explorer view button if this run yielded multiple node outputs
const outputs = getRunOutputs(state.nodeOutputs);
if (outputs.length > 1) {
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, or if it was interrupted/failed but has some outputs
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
const isInterrupted = state.status === "cancelled" || state.status === "error";
if (outputs.length > 1 || (outputs.length > 0 && isInterrupted)) {
cardObj.leftHoverBtn.style.display = "inline-flex";
} else {
cardObj.leftHoverBtn.style.display = "none";
@@ -815,13 +938,14 @@ 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) => {
// 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)) {
// Custom dragstart is used for JSON-only or unfinished workflows to supply a local DownloadURL.
// For completed image cards, dragging is handled natively by the browser on the child <img> tag.
const isUnfinished = state.status && state.status !== "completed";
if (state.workflow && (!state.images || state.images.length === 0 || isUnfinished)) {
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
const jsonStr = JSON.stringify(state.workflow, null, 2);
try {
@@ -832,14 +956,16 @@ export function renderDOM() {
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}`;
// Native drag isolation: completely remove draggable flags on image containers.
// Native drag isolation: completely remove draggable flags on image containers for finished cards.
// This exposes the child <img> tag directly to Chrome's native filesystem drag APIs.
if (state.images && state.images.length > 0) {
const isUnfinished = state.status && state.status !== "completed";
if (state.images && state.images.length > 0 && !isUnfinished) {
cardObj.element.removeAttribute("draggable");
} else {
cardObj.element.setAttribute("draggable", "true");
+21 -4
View File
@@ -1,13 +1,29 @@
import { app } from "/scripts/app.js";
export const isVideoFormat = (url) => {
const s = url.toLowerCase();
return s.includes(".mp4") || s.includes(".webm");
};
function isNodeIgnored(nodeId, workflow) {
if (!workflow || !workflow.nodes) {
// Fallback to active graph if workflow is missing (e.g. during live execution)
const liveNode = app.graph?.getNodeById(Number(nodeId));
if (liveNode) {
return !!(liveNode.properties && liveNode.properties.ignoreInQueue);
}
return false;
}
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
return !!(node && node.properties && node.properties.ignoreInQueue);
}
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
export function findImagesInOutputs(outputs) {
export function findImagesInOutputs(outputs, workflow) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
@@ -22,10 +38,11 @@ export function findImagesInOutputs(outputs) {
return list;
}
export function findTextsInOutputs(outputs) {
export function findTextsInOutputs(outputs, workflow) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
@@ -49,11 +66,11 @@ export function findTextsInOutputs(outputs) {
}
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
export function getRunOutputs(nodeOutputs) {
export function getRunOutputs(nodeOutputs, workflow) {
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] });
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
if (imgs.length > 0) {
list.push({ nodeId, images: imgs });
}