Compare commits

...
21 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
m0rtus59 edd7d2f82c Refactor: Split monolithic sidebar.js into modular ES6 architecture 2026-07-08 23:51:20 +05:00
m0rtus59 ea0cb52914 Add live execution timer and metadata search filter to sidebar 2026-07-08 23:11:37 +05:00
m0rtus59 a5b4bfcfeb add gitignore 2026-07-08 21:18:32 +05:00
m0rtus59 56c37d2a65 minor spelling fix 2026-07-08 21:15:10 +05:00
11 changed files with 2509 additions and 1160 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+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]);
}
+103
View File
@@ -0,0 +1,103 @@
import { app } from "/scripts/app.js";
async function uploadDroppedImageToInput(imageObj) {
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
try {
const response = await fetch(src);
const blob = await response.blob();
const file = new File([blob], imageObj.filename || "dropped_image.png", { type: blob.type });
const formData = new FormData();
formData.append("image", file);
formData.append("overwrite", "true");
formData.append("type", "input");
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
const uploadData = await uploadRes.json();
return uploadData.name;
} catch(e) {
console.error("Comfy Sidebar: Failed to copy image to input.", e);
return null;
}
}
export function setupDragAndDrop() {
document.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("drop", async (e) => {
const canvas = app.canvas;
if (!canvas || !canvas.graph) return;
// 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();
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 });
}
}
}
} catch (err) {
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
}
}
}, 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
});
}
+231
View File
@@ -0,0 +1,231 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage } from "./state.js";
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
export let renderDOMFn = () => {};
export let updateSidebarBadgeFn = () => {};
export function setUIDependencies(renderFn, badgeFn) {
renderDOMFn = renderFn;
updateSidebarBadgeFn = badgeFn;
}
export async function syncQueue() {
try {
const q = await api.getQueue();
const runningList = q.Running || q.queue_running || [];
const pendingList = q.Pending || q.queue_pending || [];
const pendingIds = new Set();
const normalizedPending = pendingList.map((p, idx) => {
let pid = null, seq = idx;
if (Array.isArray(p)) {
seq = typeof p[0] === 'number' ? p[0] : idx;
pid = p[1];
} else if (p && typeof p === "object") {
pid = p.prompt_id || p.id || p.uuid;
seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx);
}
return { pid, seq, original: p };
});
normalizedPending.sort((a, b) => a.seq - b.seq);
normalizedPending.forEach((item, index) => {
const pid = item.pid;
const number = index + 1;
if (pid) {
pendingIds.add(pid);
if (!promptStates.has(pid)) {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "pending", images: [], progress: 0, queueNumber: number,
progressText: `Pending... (#${number})`, timestamp: State.globalOrderCounter,
workflow: app.graph.serialize()
});
} else {
const st = promptStates.get(pid);
if (st.status === "pending") {
st.queueNumber = number;
st.progressText = `Pending... (#${number})`;
}
}
}
});
for (const [pid, state] of promptStates.entries()) {
if (state.status === "pending" && !pendingIds.has(pid)) promptStates.delete(pid);
}
updateSidebarBadgeFn(pendingIds.size + (runningList.length > 0 ? 1 : 0));
renderDOMFn();
} catch (err) {
console.error("Comfy Sidebar: Failed to sync queue state", err);
}
}
const concludeRun = async (pid, statusStr) => {
if (!pid || !promptStates.has(pid)) return;
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
const st = promptStates.get(pid);
st.status = statusStr;
st.progressText = "";
st.rendered = false;
st.endTime = Date.now();
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
try {
const res = await fetch(`/history/${pid}`);
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);
}
} catch (err) {}
pruneHistory(app);
syncQueue();
};
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();
if (promptStates.has(pid)) {
const st = promptStates.get(pid);
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
st.rendered = false; st.startTime = Date.now(); st.duration = null;
} else {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "active", images: [], progress: 0,
progressText: "Sampling...", timestamp: State.globalOrderCounter,
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
renderDOMFn(); // Render immediately to prevent pop-in delay!
syncQueue();
});
api.addEventListener("progress", (e) => {
const pid = e.detail.prompt_id;
if (pid && promptStates.has(pid)) {
promptStates.get(pid).progress = Math.round((e.detail.value / e.detail.max) * 100);
renderDOMFn();
}
});
api.addEventListener("executing", (e) => {
const nodeId = e.detail;
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
const st = promptStates.get(State.currentlyActivePromptId);
if (nodeId) {
const node = app.graph.getNodeById(nodeId);
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
} else {
st.activeNodeName = "Finishing...";
}
renderDOMFn();
}
});
api.addEventListener("b_preview", (e) => {
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
if (activeTasks.length > 0) {
const st = activeTasks[0];
if (st._previewBlobUrl) try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
st._previewBlobUrl = URL.createObjectURL(e.detail);
st.images = [{ url: st._previewBlobUrl }];
renderDOMFn();
}
});
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) {
// 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();
}
});
api.addEventListener("execution_success", (e) => concludeRun(e.detail.prompt_id, "completed"));
api.addEventListener("execution_error", (e) => concludeRun(e.detail.prompt_id, "error"));
api.addEventListener("execution_interrupted", () => {
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled"));
syncQueue();
});
}
export async function initSessionAndHistory() {
let backendSessionId = null;
try {
const res = await fetch("/classic-sidebar/session");
const data = await res.json();
backendSessionId = data.session_id;
} catch (err) {
console.warn("Comfy Sidebar: Custom session endpoint not found. Queue persistence will fall back to cache.");
}
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
if (backendSessionId && backendSessionId === storedSessionId) {
loadStatesFromLocalStorage();
} else {
localStorage.removeItem("comfy_sidebar_prompt_states");
if (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);
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,
progressText: "", timestamp: State.globalOrderCounter, rendered: true
});
});
pruneHistory(app);
await syncQueue();
}
+95 -1159
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
export const promptStates = new Map();
export const cardElements = new Map();
// Global shared variables
export const State = {
currentSearchQuery: "",
globalOrderCounter: 0,
sidebarContainer: null,
cardStack: null,
currentlyActivePromptId: null,
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
activeSubmenuBatchImages: null // Active batch images explorer pointer
};
export function pruneHistory(app) {
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
const tasks = Array.from(promptStates.entries())
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
tasks.sort((a, b) => a[1].timestamp - b[1].timestamp);
if (tasks.length > maxItems) {
const deleteCount = tasks.length - maxItems;
for (let i = 0; i < deleteCount; i++) {
const [pid] = tasks[i];
promptStates.delete(pid);
cardElements.delete(pid);
}
}
}
export function saveStatesToLocalStorage() {
try {
const serializable = [];
for (const [pid, state] of promptStates.entries()) {
const cleanedImages = (state.images || []).map(img => {
if (img.url && img.url.startsWith("blob:")) return null; // Skip blob URLs
return img;
}).filter(Boolean);
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,
startTime: state.startTime, endTime: state.endTime, duration: state.duration
});
}
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
} catch (e) {
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
}
}
export function loadStatesFromLocalStorage() {
try {
const data = localStorage.getItem("comfy_sidebar_prompt_states");
if (data) {
const list = JSON.parse(data);
list.forEach(state => {
promptStates.set(state.pid, state);
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
});
}
} catch (e) {
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
}
}
+60
View File
@@ -0,0 +1,60 @@
// styles.js
export function injectStyles() {
const style = document.createElement("style");
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
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;
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 .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);
}
+1092
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
export const isVideoFormat = (url) => {
const s = url.toLowerCase();
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;
for (const nodeId in outputs) {
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (item && typeof item === 'object' && item.filename) list.push(item);
});
} else if (val && typeof val === 'object' && val.filename) {
list.push(val);
}
}
}
return list;
}
export function findTextsInOutputs(outputs) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (typeof item === 'string') {
list.push(item);
} else if (item && typeof item === 'object' && item.text) {
if (Array.isArray(item.text)) list.push(...item.text);
else if (typeof item.text === 'string') list.push(item.text);
}
});
} else if (typeof val === 'string') {
list.push(val);
} else if (val && typeof val === 'object' && val.text) {
if (Array.isArray(val.text)) list.push(...val.text);
else if (typeof val.text === 'string') list.push(val.text);
}
}
}
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();
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
if (state.workflow && Array.isArray(state.workflow.nodes)) {
for (const node of state.workflow.nodes) {
if (node.title && node.title.toLowerCase().includes(q)) return true;
if (node.type && node.type.toLowerCase().includes(q)) return true;
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (w && w.value !== undefined && w.value !== null) {
if (String(w.value).toLowerCase().includes(q)) return true;
}
}
}
}
}
return false;
}