Compare commits

..
17 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
m0rtus59 984cd108d6 Improve video preview 2026-07-11 20:43:41 +05:00
m0rtus59 ce2379b35c Add features for cleaning the queue 2026-07-09 00:47:39 +05:00
m0rtus59 2a975bdc9f Capped maximum text card preview length 2026-07-09 00:06:26 +05:00
10 changed files with 1766 additions and 190 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
});
}
+32 -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);
}
@@ -92,6 +93,20 @@ export function setupApiListeners() {
api.addEventListener("status", syncQueue);
api.addEventListener("execution_start", (e) => {
// Auto-sweep cancelled and errored jobs if setting is enabled
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
const toDelete = [];
for (const [p, s] of promptStates.entries()) {
if (s.status === "cancelled" || s.status === "error") {
toDelete.push(p);
promptStates.delete(p);
}
}
if (toDelete.length > 0) {
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
}
}
const pid = e.detail.prompt_id;
State.currentlyActivePromptId = pid;
const activeWorkspaceWorkflow = app.graph.serialize();
@@ -108,6 +123,7 @@ export function setupApiListeners() {
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
renderDOMFn(); // Render immediately to prevent pop-in delay!
syncQueue();
});
@@ -148,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();
}
});
@@ -192,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
});
+65 -1
View File
@@ -1,23 +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 });
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,
+37 -4
View File
@@ -1,3 +1,4 @@
// styles.js
export function injectStyles() {
const style = document.createElement("style");
style.textContent = `
@@ -6,22 +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);
}
+776 -124
View File
File diff suppressed because it is too large Load Diff
+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();