Compare commits

...
6 Commits
Author SHA1 Message Date
m0rtus59 25520c8612 Update readme. Fix compare slider. Version bump 2026-09-26 20:19:06 +05:00
m0rtus59 949220a260 New 'runtime preview' mode 2026-09-26 00:09:42 +05:00
m0rtus59 e3d943a353 More fullscreen preview improvements 2026-09-25 22:09:07 +05:00
m0rtus59 929928aa97 Fullscreen preview improvements 2026-09-25 21:52:16 +05:00
m0rtus59 dc868f298f Cache integrity check fix 2026-09-25 21:13:14 +05:00
m0rtus59 cdd4d399c8 Add comfy registry icon 2026-09-18 21:41:12 +05:00
17 changed files with 845 additions and 189 deletions
+47 -36
View File
@@ -1,70 +1,81 @@
# ComfyUI-ComfySidebar
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage.
## ✨ Features
* Browse generation results in a compact grid.
* Drag results back onto the canvas to restore workflows.
* Drag images directly into `LoadImage` and compatible input nodes.
* Preview images, videos, and text outputs.
* Compare two results side by side with `Shift+Click`.
* Download generated files or workflow JSON directly from result cards.
* Show queue progress and the node currently being executed.
* Hide sidebar tabs and UI elements you don't use.
* Optionally replace the stock Job History sidebar.
* Optional unified top bar for workflow tabs and controls.
* Exclude selected nodes from sidebar results with `Ctrl+Q` (or the header button).
* Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
* Ctrl-clicking the delete button places the generated file to the recycle bin.
* [NEW] Reset all node IDs in topological execution order (`Alt+R`, right-click menu, or header button) to ensure the final output node is always prioritized as the primary preview.
* **Compact, information-dense layout** with visual previews that let you see what is happening at a glance. Optional UI elements and sidebar tabs can be hidden when you don't need them.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
<img src="assets/layout.png" alt="ComfySidebar layout" width="80%">
* **Left or right side:** Choose which side the sidebar appears on in ComfyUI settings.
* **Simplified mouse-friendly workflow** with drag-and-drop support and convenient actions for moving results back into the workflow, opening files, and managing outputs. Hold `Ctrl` for extra actions such as opening an image's location or deleting it directly from your device.
<img src="assets/controls.png" alt="ComfySidebar controls overview" width="80%">
* **Built-in comparison tool** for quickly comparing two generated results side by side.
<img src="assets/compare.png" alt="ComfySidebar compare" width="80%">
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
<img src="assets/runtime.png" alt="ComfySidebar runtime mode" width="80%">
## 🛠️ Installation
Clone the repository directly into your ComfyUI custom nodes directory:
### ComfyUI Registry
Install directly from the ComfyUI Manager or run:
```bash
comfy node install comfy-sidebar
```
ComfySidebar is available on the [ComfyUI Registry](https://registry.comfy.org/publishers/soundslikethunder/nodes/comfy-sidebar).
### Manual installation
Clone the repository into your ComfyUI custom nodes directory:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/m0rtus59/ComfyUI-ComfySidebar.git
```
Restart your ComfyUI server and refresh the browser tab.
Restart ComfyUI and refresh the browser.
## ⚙️ Configuration
## ⚙️ Settings
Open **Settings → Comfy Sidebar** to configure:
* Queue grid layout
* Result aspect ratio
* Queue layout and column width
* Queue display mode
* Working node display
* Automatic cleanup of cancelled/failed jobs
* Automatic cleanup of cancelled or failed jobs
* Sidebar tabs to hide
* Stock Job History replacement
* Graph button visibility
* Unified top bar layout
* Permanent deletion behavior
## ⌨️ Shortcuts
## ⌨️ Controls
| Shortcut | Action |
| ------------- | -------------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
| `Alt+R` | Reset all node IDs by execution order |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open file location/alternative action |
| Shortcut | Action |
| ------------- | ---------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Exclude the selected node from sidebar results |
| `Alt+R` | Renumber nodes in execution order |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open the file location / alternative action |
You can also use the available actions from result cards, the sidebar header, and the node/canvas context menus.
## 📝 Notes
ComfySidebar integrates with the ComfyUI frontend and therefore depends on parts of its UI structure. Frontend changes in ComfyUI may require corresponding updates to this extension.
ComfySidebar integrates with the ComfyUI frontend and depends on parts of its UI structure. Frontend changes in ComfyUI may require updates to this extension.
## License
See [LICENSE](LICENSE).
See [LICENSE](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+79 -11
View File
@@ -43,15 +43,36 @@ export function findTopbarContainer() {
return null;
}
export function isPropertiesPanelOpen() {
const panel = document.querySelector('[data-testid="properties-panel"]');
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
const headings = document.querySelectorAll('h1, h2, h3, h4');
for (const h of headings) {
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
export function findPropertiesPanel() {
const testIdPanel = document.querySelector('[data-testid="properties-panel"], [data-testid="workflow-overview-panel"], .properties-panel, [class*="properties-panel"]');
if (testIdPanel && testIdPanel.offsetWidth > 0 && testIdPanel.offsetHeight > 0 && testIdPanel.isConnected) {
return testIdPanel;
}
const headings = document.querySelectorAll('h1, h2, h3, h4, [class*="title"]');
for (const h of headings) {
if (h.textContent.trim().toLowerCase() === "workflow overview" && h.offsetWidth > 0 && h.offsetHeight > 0) {
const container = h.closest('.p-sidebar, .sidebar, aside, [class*="side-panel"], [class*="panel"], [class*="properties"]') || h.parentElement;
if (container && container.offsetWidth > 0 && container !== document.body) {
return container;
}
}
}
const rightSidebars = document.querySelectorAll('.p-sidebar-right, aside.p-sidebar, [class*="p-sidebar-right"]');
for (const sb of rightSidebars) {
if (sb.offsetWidth > 0 && sb.offsetHeight > 0) {
return sb;
}
}
return null;
}
export function isPropertiesPanelOpen() {
const panel = findPropertiesPanel();
if (panel) return true;
const origBtn = findOriginalPropertiesButton();
if (origBtn) {
if (origBtn.classList.contains("p-button-active") ||
@@ -64,6 +85,56 @@ export function isPropertiesPanelOpen() {
return false;
}
export function findActiveSidebars() {
const leftPanels = [];
const rightPanels = [];
const winW = window.innerWidth;
const midX = winW / 2;
// 1. Check if our Queue sidebar is open and visible
const queueHeader = document.querySelector('.comfy-sidebar-header-root');
if (queueHeader) {
const queueDrawer = queueHeader.closest('.sidebar-content-container, .comfyui-sidebar-content, .p-sidebar, aside') || queueHeader.parentElement;
if (queueDrawer && queueDrawer.offsetWidth > 0 && queueDrawer.offsetHeight > 0 && queueDrawer.isConnected) {
const r = queueDrawer.getBoundingClientRect();
if (r.right > 0 && r.right < midX) {
leftPanels.push(queueDrawer);
}
}
}
// 2. Query all active sidebars and panels across ComfyUI & third-party extensions
const candidates = document.querySelectorAll(
'aside, nav, .comfyui-sidebar, .side-tool-bar-container, .sidebar-content-container, ' +
'.comfyui-sidebar-content, .p-sidebar, [data-testid="properties-panel"], ' +
'[data-testid="workflow-overview-panel"], [data-testid*="sidebar"], .p-sidebar-right'
);
const seen = new Set(leftPanels);
for (const el of candidates) {
if (!el.isConnected || el.offsetWidth < 30 || el.offsetHeight < 120) continue;
if (el.closest('.comfy-sidebar-comparison-overlay, .p-dialog, [role="dialog"], .comfy-modal, .comfy-menu')) continue;
if (seen.has(el)) continue;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0 || rect.top > 150) continue;
// Left-docked panels (Icon bar or an open drawer on the left)
if (rect.left <= 100 && rect.right > 0 && rect.right < midX) {
leftPanels.push(el);
}
// Right-docked panels (Workflow Overview or right inspector drawers)
else if (rect.right >= winW - 80 && rect.left > midX && rect.left < winW) {
rightPanels.push(el);
}
}
return { leftPanels, rightPanels };
}
export function findActiveQueueIndicator() {
const container = findTopbarContainer();
if (!container) return null;
@@ -111,13 +182,11 @@ export function findOurSidebarButton() {
}
export function findStandardQueueButton() {
// 1. Try modern ComfyUI stable test-ids first
const byTestId = document.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"]');
if (byTestId && !byTestId.id?.includes('classic-comfy-sidebar') && !byTestId.querySelector('.pi-images')) {
return byTestId;
}
// 2. Fallback to icon discovery
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h", '[class*="lucide--history"]']) {
const icon = document.querySelector(iconSelector);
if (icon) {
@@ -126,7 +195,6 @@ export function findStandardQueueButton() {
}
}
// 3. Fallback to semantic labels
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
for (const btn of buttons) {
const title = btn.title || btn.getAttribute('aria-label') || '';
@@ -208,4 +276,4 @@ export function centerAndSelectCanvasNode(nodeId) {
return true;
}
return false;
}
}
+90 -3
View File
@@ -5,8 +5,15 @@ import {
isPropertiesPanelOpen,
findActiveQueueIndicator,
findNativeExtensionsPanel,
findGraphButton
findGraphButton,
findPropertiesPanel
} from "./adapter.js";
import { isRuntimePreviewEnabled, setRuntimePreviewEnabled, setRuntimePreviewStateListener } from "../utils/comparison.js";
// Listen for runtime preview state changes to keep button UI in sync
setRuntimePreviewStateListener(() => {
syncRuntimePreviewButton();
});
const STYLE_ID = "comfy-sidebar-classic-layout-override";
@@ -163,7 +170,6 @@ function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
// Use stable ComfyUI data-testid selectors first, with icon classes as safe fallback
const tabSelectors = {
"Assets": '[data-testid="assets-tab-button"], [class*="comfy--image-ai-edit"]',
"Nodes": '[data-testid="node-library-tab-button"], [class*="comfy--node"]',
@@ -356,6 +362,86 @@ function syncActiveQueueIndicator(isClassicLayoutEnabled) {
}
}
export function syncRuntimePreviewButton() {
const panel = findPropertiesPanel();
if (!panel) return;
let dock = panel.querySelector(".comfy-sidebar-runtime-preview-dock");
if (!dock) {
dock = document.createElement("div");
dock.className = "comfy-sidebar-runtime-preview-dock";
Object.assign(dock.style, {
position: "sticky",
bottom: "0px",
left: "0px",
width: "100%",
padding: "8px 12px",
boxSizing: "border-box",
background: "var(--comfy-menu-bg, #181818)",
borderTop: "1px solid var(--border-color, rgba(255, 255, 255, 0.12))",
zIndex: "20",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: "auto"
});
const btn = document.createElement("button");
btn.className = "comfy-sidebar-runtime-preview-btn";
Object.assign(btn.style, {
width: "100%",
height: "34px",
borderRadius: "6px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
fontSize: "12px",
fontWeight: "600",
fontFamily: "sans-serif",
transition: "all 0.15s ease",
outline: "none"
});
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const nextState = !isRuntimePreviewEnabled();
setRuntimePreviewEnabled(nextState, true);
};
dock.appendChild(btn);
panel.appendChild(dock);
}
const btn = dock.querySelector(".comfy-sidebar-runtime-preview-btn");
if (btn) {
const active = isRuntimePreviewEnabled();
const lucidePlay = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><polygon points="6 3 20 12 6 21 6 3"/></svg>`;
const lucideEye = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0;"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
const lucideSquareCheck = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/></svg>`;
const lucideSquare = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5;flex-shrink:0;"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>`;
if (active) {
btn.style.background = "rgba(59, 130, 246, 0.18)";
btn.style.border = "1px solid #3b82f6";
btn.style.color = "#93c5fd";
btn.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.35)";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucidePlay}<span>Runtime Preview</span></span>${lucideSquareCheck}`;
btn.title = "Runtime Preview is ON (auto-tracking live generation). Click to turn OFF.";
} else {
btn.style.background = "rgba(255, 255, 255, 0.05)";
btn.style.border = "1px solid var(--border-color, rgba(255, 255, 255, 0.15))";
btn.style.color = "var(--desc-color, #aaa)";
btn.style.boxShadow = "none";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucideEye}<span>Runtime Preview</span></span>${lucideSquare}`;
btn.title = "Click to turn ON Runtime Preview (auto-focus live generation).";
}
}
}
export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
@@ -367,6 +453,7 @@ export function syncClassicLayout() {
syncPropertiesButton(isClassicLayoutEnabled);
syncActiveQueueIndicator(isClassicLayoutEnabled);
updateSidebarTabsVisibility();
syncRuntimePreviewButton();
} catch (err) {
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
@@ -462,4 +549,4 @@ export function destroyLayoutFix() {
if (styleEl) styleEl.remove();
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
if (fixStyles) fixStyles.remove();
}
}
+6 -1
View File
@@ -42,6 +42,7 @@ export function activatePrompt(state, patch = {}) {
state.rendered = false;
state.startTime = state.startTime || Date.now();
state.duration = null;
state._hasValidatedIntermediates = true; // New live prompt; files are fresh
if (patch.workflow) state.workflow = patch.workflow;
if (patch.activeNodeName !== undefined) state.activeNodeName = patch.activeNodeName;
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
@@ -58,10 +59,14 @@ export function updatePromptProgress(state, progress, activeNodeName = null) {
}
export function finalizePrompt(state, targetStatus, patch = {}) {
revokePromptBlobs(state);
// Only revoke blobs if not cancelled; preserve the preview so the user can inspect the cancelled step
if (targetStatus !== PromptStatus.CANCELLED && state.status !== PromptStatus.CANCELLED) {
revokePromptBlobs(state);
}
state.status = targetStatus;
state.progressText = "";
state.rendered = false;
state._hasValidatedIntermediates = true; // Live session generation; files are fresh
state.endTime = patch.endTime || Date.now();
if (state.startTime) {
state.duration = (state.endTime - state.startTime) / 1000;
+10 -1
View File
@@ -5,7 +5,7 @@ import { PromptStatus } from "../core/constants.js";
import { createPromptState, activatePrompt, updatePromptProgress } from "../core/promptState.js";
import { syncQueue, concludeRun, initSessionAndHistory, clearCancelledOrFailed } from "./queueService.js";
import { findImagesInOutputs, findTextsInOutputs } from "../utils/utils.js";
import { updateActiveComparisonPreview } from "../utils/comparison.js";
import { updateActiveComparisonPreview, isRuntimePreviewEnabled, focusRuntimePreviewTarget, getActiveComparisonViewer, showFullscreenPreview } from "../utils/comparison.js";
export function setupExecutionTracker(onTargetedProgressUpdate) {
const onStatus = () => syncQueue();
@@ -45,6 +45,10 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
store.setPrompt(pid, newState);
}
if (isRuntimePreviewEnabled()) {
focusRuntimePreviewTarget();
}
syncQueue();
};
@@ -100,6 +104,11 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
// Update live fullscreen preview if currently open
updateActiveComparisonPreview(prompt.pid, newBlobUrl);
// If Runtime Preview mode is enabled but the overlay was closed, auto-open it
if (isRuntimePreviewEnabled() && !getActiveComparisonViewer()) {
showFullscreenPreview([newBlobUrl], false, prompt.pid);
}
// Update the active card in the sidebar
if (typeof onTargetedProgressUpdate === "function") {
onTargetedProgressUpdate(prompt.pid, prompt.progress, prompt.activeNodeName);
+133 -42
View File
@@ -113,7 +113,6 @@ export function syncCardButtonVisibility(cardObj, state) {
}
if (cardObj.btnDel) {
// Always show the delete button so cards can be cleared from history
cardObj.btnDel.style.removeProperty("display");
cardObj.btnDel.style.display = "inline-flex";
@@ -127,7 +126,6 @@ export function syncCardButtonVisibility(cardObj, state) {
}
if (cardObj.btnFocus) {
// Focus the image's node, or the node where the workflow stopped/errored
const nodeId = (currentImg ? findNodeIdForImage(state, currentImg) : null) || state.activeNodeId;
if (nodeId) {
cardObj.btnFocus.style.removeProperty("display");
@@ -144,53 +142,123 @@ export function syncCardButtonVisibility(cardObj, state) {
if (cardObj.leftHoverBtn) {
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
const validOutputs = outputs.filter(o => o.images && o.images.length > 0);
const validOutputs = outputs
.map(o => ({
...o,
images: (o.images || []).filter(img => img && img.filename && !img.isFallback)
}))
.filter(o => o.images.length > 0);
const primarySignatures = new Set((state.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
const distinctOutputs = [];
for (const out of validOutputs) {
const hasNewImage = out.images.some(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
if (hasNewImage) {
distinctOutputs.push(out);
const distinctImages = out.images.filter(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
if (distinctImages.length > 0) {
distinctOutputs.push({ ...out, images: distinctImages });
}
}
// Show intermediate button if run completed with multiple outputs, or if stopped with saved outputs
const hasIntermediates = (validOutputs.length > 1 && distinctOutputs.length > 0) ||
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
if (hasIntermediates) {
// Check if intermediate files still exist on disk
for (const out of distinctOutputs) {
for (const img of out.images) {
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
if (!src.startsWith("blob:")) {
fetch(src, { method: "HEAD" }).then(res => {
if (res.status === 404) {
const liveState = store.getPrompt(state.pid);
if (liveState && liveState.nodeOutputs) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
store.updatePrompt(liveState.pid, liveState);
syncCardButtonVisibility(cardObj, liveState);
if (!hasIntermediates) {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
} else {
// If already verified (or freshly generated), display button immediately
if (state._hasValidatedIntermediates) {
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
} else {
// Old restored card: keep hidden by default while stealthily testing files
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
if (!cardObj._isCheckingIntermediates) {
cardObj._isCheckingIntermediates = true;
const checkPromises = [];
const missingImages = [];
for (const out of distinctOutputs) {
for (const img of out.images) {
if (!img.filename || (img.url && img.url.startsWith("blob:"))) continue;
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
checkPromises.push(
fetch(src, { method: "HEAD" })
.then(res => {
if (res.status === 404) {
missingImages.push(img);
}
})
.catch(() => {
missingImages.push(img);
})
);
}
}
const finalizeCheck = () => {
cardObj._isCheckingIntermediates = false;
const liveState = store.getPrompt(state.pid) || state;
liveState._hasValidatedIntermediates = true;
if (missingImages.length > 0 && liveState && liveState.nodeOutputs) {
for (const img of missingImages) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
}
for (const nodeId in liveState.nodeOutputs) {
const nodeVal = liveState.nodeOutputs[nodeId];
if (nodeVal && typeof nodeVal === "object") {
const hasAny = Object.values(nodeVal).some(v => Array.isArray(v) ? v.length > 0 : Boolean(v));
if (!hasAny) {
delete liveState.nodeOutputs[nodeId];
}
}
}
}).catch(() => {});
store.updatePrompt(liveState.pid, liveState);
}
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
const curValid = curOutputs
.map(o => ({
...o,
images: (o.images || []).filter(i => i && i.filename && !i.isFallback)
}))
.filter(o => o.images.length > 0);
const curDistinct = curValid.filter(o => o.images.some(i => !primarySignatures.has(`${i.subfolder || ""}/${i.filename}`)));
const stillHas = (curValid.length > 1 && curDistinct.length > 0) ||
(liveState.status !== PromptStatus.COMPLETED && curValid.length > 0);
if (stillHas) {
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
} else {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
}
};
if (checkPromises.length > 0) {
Promise.all(checkPromises).then(finalizeCheck).catch(finalizeCheck);
} else {
finalizeCheck();
}
}
}
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
cardObj.leftHoverBtn.onclick = async (ev) => {
ev.stopPropagation();
// On click: verify distinct outputs still exist before opening
const liveState = store.getPrompt(state.pid) || state;
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
const curValid = curOutputs
.map(o => ({
...o,
images: (o.images || []).filter(i => i && i.filename && !i.isFallback)
}))
.filter(o => o.images.length > 0);
const primSigs = new Set((liveState.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
const curDistinct = curOutputs.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
const curDistinct = curValid.filter(o => o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
if (curDistinct.length === 0) {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
@@ -200,6 +268,7 @@ export function syncCardButtonVisibility(cardObj, state) {
let anyMissing = false;
for (const out of curDistinct) {
for (const img of out.images) {
if (primSigs.has(`${img.subfolder || ""}/${img.filename}`)) continue;
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
if (!src.startsWith("blob:")) {
try {
@@ -214,12 +283,23 @@ export function syncCardButtonVisibility(cardObj, state) {
}
if (anyMissing) {
for (const nodeId in liveState.nodeOutputs) {
const nodeVal = liveState.nodeOutputs[nodeId];
if (nodeVal && typeof nodeVal === "object") {
const hasAny = Object.values(nodeVal).some(v => Array.isArray(v) ? v.length > 0 : Boolean(v));
if (!hasAny) {
delete liveState.nodeOutputs[nodeId];
}
}
}
store.updatePrompt(liveState.pid, liveState);
syncCardButtonVisibility(cardObj, liveState);
const remaining = getRunOutputs(liveState.nodeOutputs, liveState.workflow)
.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
const remainingOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
const remaining = remainingOutputs
.map(o => ({ ...o, images: (o.images || []).filter(i => i && i.filename && !i.isFallback) }))
.filter(o => o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
if (remaining.length === 0) {
return; // All intermediate outputs were wiped; button is now hidden
return;
}
}
@@ -231,8 +311,6 @@ export function syncCardButtonVisibility(cardObj, state) {
}
store.openOutputsSubmenu(liveState.pid);
};
} else {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
}
}
}
@@ -248,7 +326,6 @@ export function updateCardProgressTargeted(cardObj, progress, activeNodeName, sh
cardObj.statusText.textContent = `${nodeText} ${progress}%`;
}
// Fast-path: update live preview image directly on active card
if (state && state.images && state.images.length > 0) {
const firstImg = state.images[0];
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
@@ -279,14 +356,15 @@ export function getOrCreateCard(state, callbacks = {}) {
const cancelX = document.createElement("span");
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
Object.assign(cancelX.style, {
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "10"
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "25"
});
const sBadge = document.createElement("div");
sBadge.className = "comfy-sidebar-status-badge";
Object.assign(sBadge.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold",
padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none",
pointerEvents: "none", zIndex: "10"
position: "absolute", top: "6px", right: "8px", fontSize: "10px", fontWeight: "bold",
padding: "3px 7px", borderRadius: "3px", textTransform: "uppercase", display: "none",
pointerEvents: "none", zIndex: "30", letterSpacing: "0.5px", boxShadow: "0 2px 6px rgba(0,0,0,0.6)"
});
const dimEl = document.createElement("div");
@@ -366,7 +444,8 @@ export function getOrCreateCard(state, callbacks = {}) {
hoverPanel.append(btnImg, btnJson, btnDel);
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
card.append(timerEl, cancelX, sBadge, dimEl, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
// Appending grid FIRST ensures overlays (badges, cancel button) naturally render on top
card.append(grid, p, pt, statusText, timerEl, cancelX, dimEl, sBadge, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
@@ -391,11 +470,11 @@ export function getOrCreateCard(state, callbacks = {}) {
statusText,
firstImgElement: null,
lastImagesSignature: "",
currentImageIndex: 0
currentImageIndex: 0,
_isCheckingIntermediates: false
};
card.addEventListener("mouseenter", () => {
// Only run automatic button visibility sync on main queue cards (avoid synthetic submenu IDs)
if (store.hasPrompt(state.pid)) {
const liveState = store.getPrompt(state.pid);
syncCardButtonVisibility(cardObj, liveState);
@@ -515,11 +594,23 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
}
if (state.status === PromptStatus.CANCELLED) {
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" });
Object.assign(cardObj.statusBadge.style, {
display: "block",
background: "#eab308",
color: "#000",
zIndex: "30"
});
cardObj.statusBadge.textContent = "Cancelled";
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
} else if (state.status === PromptStatus.ERROR) {
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" });
Object.assign(cardObj.statusBadge.style, {
display: "block",
background: "#dc3545",
color: "#fff",
zIndex: "30"
});
cardObj.statusBadge.textContent = "Error";
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
} else {
cardObj.statusBadge.style.display = "none";
}
@@ -579,7 +670,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
cardObj.placeholder.onclick = (e) => {
e.stopPropagation();
stopAllAudioPlayback();
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
showFullscreenPreview([{ text: fullText, pid: state.pid }], false, state.pid);
};
} else {
cardObj.placeholder.textContent = state.progressText || "No Outputs";
+8 -8
View File
@@ -70,7 +70,7 @@ export function render3DCardPreview(cardObj, wrapper, src, img, state) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl], ev.shiftKey);
showFullscreenPreview([fullUrl], ev.shiftKey, state?.pid);
};
preview3D.setAttribute("draggable", "true");
@@ -156,6 +156,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" });
scrubberFill.style.borderRadius = "2px";
scrubber.appendChild(scrubberFill);
const timeLabel = document.createElement("div");
@@ -205,7 +206,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
if (isAudioViewerOpen()) {
stopAllAudioPlayback();
showFullscreenPreview([fullUrl]);
showFullscreenPreview([fullUrl], false, state?.pid);
return;
}
@@ -252,7 +253,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl]);
showFullscreenPreview([fullUrl], false, state?.pid);
};
}
@@ -508,7 +509,7 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
}
// If the card still has other images (e.g. from a batch), persist and switch to next image
// If the card still has other images, persist and switch
if (liveState.images && liveState.images.length > 0) {
cardObj.currentImageIndex = 0;
cardObj.lastImagesSignature = "";
@@ -516,14 +517,13 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
return;
}
// If the card has text outputs or is still in progress, persist
// If the card has text outputs or is in progress, persist
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
// Only delete the main card when all items belonging to it are gone
store.deletePrompt(liveState.pid);
cardObj.element?.remove();
return;
@@ -535,7 +535,7 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
const applyDimensions = (width, height) => {
if (cardObj.dimEl && width && height) {
// Don't show the dimension badge for active/running preview frames
if (state.status === "active") {
if (state.status === "active" || state.status === "cancelled" || state.status === "error") {
cardObj.dimEl.style.display = "none";
return;
}
@@ -713,4 +713,4 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
} else {
if (navBar) navBar.remove();
}
}
}
+273 -40
View File
@@ -3,17 +3,108 @@ import { create3DViewer } from "./viewer3d.js";
import { createTextReader } from "./text_reader.js";
import { stopAllAudioPlayback } from "../ui/audioController.js";
import { SidebarOverlay } from "./overlay.js";
import { store } from "../core/store.js";
import { PromptStatus } from "../core/constants.js";
let activeComparisonViewer = null;
let isRuntimePreviewActive = false;
let onRuntimePreviewStateChange = null;
export function isRuntimePreviewEnabled() {
return isRuntimePreviewActive;
}
export function getActiveComparisonViewer() {
return activeComparisonViewer;
}
export function setRuntimePreviewStateListener(fn) {
onRuntimePreviewStateChange = fn;
}
export function setRuntimePreviewEnabled(enabled, triggerFocus = true) {
isRuntimePreviewActive = !!enabled;
if (typeof onRuntimePreviewStateChange === "function") {
try { onRuntimePreviewStateChange(isRuntimePreviewActive); } catch (e) {}
}
if (isRuntimePreviewActive && triggerFocus) {
focusRuntimePreviewTarget();
}
}
function isLatestOrActiveTask(pid) {
if (!pid) return false;
try {
const targetPid = String(pid);
const activePid = store.ui.currentlyActivePromptId;
// If a job is actively generating in the background:
// ONLY that active job is considered active. Any other card is an older card!
if (activePid) {
return String(activePid) === targetPid;
}
// If the queue is idle, only the newest prompt in history is the latest
const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
if (all.length > 0 && String(all[0].pid) === targetPid) {
return true;
}
} catch (e) {}
return false;
}
export function focusRuntimePreviewTarget() {
try {
const activePid = store.ui.currentlyActivePromptId;
const activePrompt = activePid ? store.getPrompt(activePid) : null;
// 1. If a prompt is actively running, focus it (even if it has no image yet)
if (activePrompt && activePrompt.status === PromptStatus.ACTIVE) {
const previewSrc = activePrompt._previewBlobUrl ||
(activePrompt.images && activePrompt.images.length > 0 ? (activePrompt.images[0].url || activePrompt.images[0].filename) : null);
showFullscreenPreview([previewSrc || ""], false, activePrompt.pid);
return;
}
// 2. Otherwise focus the latest task in history (even if cancelled or text-only)
const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
const latest = all[0];
if (latest) {
const firstImg = latest.images && latest.images.length > 0 ? latest.images[0] : null;
const randParam = latest.pid ? `&rand=${encodeURIComponent(latest.pid)}` : "";
const src = latest._previewBlobUrl ||
(firstImg ? (firstImg.url || `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}`) : "");
showFullscreenPreview([src || ""], false, latest.pid);
} else {
// Queue is totally empty
showFullscreenPreview([""], false, null);
}
} catch (e) {
console.error("Comfy Sidebar: Error focusing runtime preview target:", e);
}
}
export function isAudioViewerOpen() {
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
}
export function updateActiveComparisonPreview(pid, newSrc) {
if (activeComparisonViewer && activeComparisonViewer.targetPid === String(pid)) {
activeComparisonViewer.loadTarget(newSrc);
if (!activeComparisonViewer) return;
const targetPidStr = String(pid);
// If Runtime Preview is OFF: NEVER interrupt the user while inspecting older cards!
if (!isRuntimePreviewActive) {
if (activeComparisonViewer.targetPid === targetPidStr) {
activeComparisonViewer.loadTarget(newSrc);
}
return;
}
// If Runtime Preview is ON: always track the active generation
activeComparisonViewer.targetPid = targetPidStr;
activeComparisonViewer.loadTarget(newSrc);
}
const getClientX = (e) => {
@@ -33,6 +124,9 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
const overlay = new SidebarOverlay({
onDestroy,
onUserClose: () => {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
},
onKeyDown: (e) => {
if (e.key === " ") {
e.preventDefault();
@@ -47,7 +141,8 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "12px",
padding: "24px 28px", width: "90%", maxWidth: "520px",
boxShadow: "0 8px 32px rgba(0,0,0,0.7)", display: "flex",
flexDirection: "column", gap: "16px", zIndex: "20"
flexDirection: "column", gap: "16px", zIndex: "20",
pointerEvents: "auto"
});
const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav";
@@ -370,6 +465,9 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const overlay = new SidebarOverlay({
onDestroy,
onUserClose: () => {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
},
onKeyDown: (e) => {
if (e.key === " " && mediaB) {
e.preventDefault();
@@ -378,12 +476,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
}
});
// Inset 6px left/right so sidebar resize handles stay 100% accessible
const scrollContainer = document.createElement("div");
Object.assign(scrollContainer.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
position: "absolute", top: "0", left: "6px", right: "6px", width: "auto", height: "100%",
display: "flex", overflow: "auto", boxSizing: "border-box",
padding: "48px 24px", scrollbarWidth: "thin",
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15",
pointerEvents: "auto"
});
overlay.container.appendChild(scrollContainer);
@@ -410,14 +510,70 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
margin: "auto", flexShrink: "0",
cursor: isBaseVideo ? "default" : "zoom-in"
cursor: isBaseVideo ? "default" : "zoom-in",
pointerEvents: "auto"
});
scrollContainer.appendChild(wrapper);
// Large 60px Lucide Spinner with relaxed 1.3s rotation
const showPlaceholder = () => {
mediaA.style.display = "none";
if (mediaB) {
cleanupMedia(mediaB);
mediaB.remove();
mediaB = null;
}
slider.style.display = "none";
let placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder");
if (!placeholder) {
placeholder = document.createElement("div");
placeholder.className = "comfy-sidebar-viewer-placeholder";
Object.assign(placeholder.style, {
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
padding: "36px", borderRadius: "12px", color: "#94a3b8", textAlign: "center",
userSelect: "none", pointerEvents: "auto", minWidth: "120px", minHeight: "120px",
boxSizing: "border-box", margin: "auto"
});
wrapper.appendChild(placeholder);
}
const spinnerSvg = `
<style>
@keyframes comfy-sidebar-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation: comfy-sidebar-spin 1.3s linear infinite; filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.45));">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
`;
placeholder.innerHTML = spinnerSvg;
placeholder.style.display = "flex";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = "default";
};
const hidePlaceholder = () => {
const placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder");
if (placeholder) placeholder.style.display = "none";
mediaA.style.display = "block";
};
let mediaA = createMediaElement(baseSrc, false);
mediaA.onerror = () => {
showPlaceholder();
};
wrapper.appendChild(mediaA);
let isZoomed = false;
if (!baseSrc) {
showPlaceholder();
}
const slider = document.createElement("div");
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
@@ -457,16 +613,32 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
if (!mediaA) return;
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
if (!wA || !hA) return;
// Calculate available space inside the preview area
const availW = Math.max(100, (scrollContainer.clientWidth || window.innerWidth) - 48);
const availH = Math.max(100, (scrollContainer.clientHeight || window.innerHeight) - 96);
const aspectA = wA / hA;
// Scale to fit available space while maintaining aspect ratio (fills screen even for small previews)
let fitW = availW;
let fitH = fitW / aspectA;
if (fitH > availH) {
fitH = availH;
fitW = fitH * aspectA;
}
fitW = Math.round(fitW);
fitH = Math.round(fitH);
if (!mediaB) {
slider.style.display = "none";
wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in");
if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto";
if (isZoomed && wA && hA) {
if (isZoomed) {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${wA}px`;
wrapper.style.height = `${hA}px`;
wrapper.style.cursor = "zoom-out";
mediaA.style.maxWidth = "none";
mediaA.style.maxHeight = "none";
mediaA.style.width = "100%";
@@ -475,21 +647,20 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
} else {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
mediaA.style.maxWidth = "calc(100vw - 120px)";
mediaA.style.maxHeight = "calc(100vh - 120px)";
mediaA.style.width = "auto";
mediaA.style.height = "auto";
mediaA.style.objectFit = "contain";
wrapper.style.width = `${fitW}px`;
wrapper.style.height = `${fitH}px`;
mediaA.style.maxWidth = "none";
mediaA.style.maxHeight = "none";
mediaA.style.width = "100%";
mediaA.style.height = "100%";
mediaA.style.objectFit = "fill";
}
return;
}
const wB = mediaB.naturalWidth || mediaB.videoWidth || 0;
const hB = mediaB.naturalHeight || mediaB.videoHeight || 0;
if (!wA || !hA || !wB || !hB) return;
if (!wB || !hB) return;
const arA = wA / hA;
const arB = wB / hB;
@@ -514,21 +685,31 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
}
slider.style.display = "block";
wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in");
if (mediaA) mediaA.style.pointerEvents = "none";
if (mediaB) mediaB.style.pointerEvents = "none";
infoText.style.color = "#aaa";
infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card to compare | Esc to close.";
infoText.textContent = "Click image to zoom | Drag image to compare slider | Esc to close.";
updateSliderPosition(50);
const maxW = Math.max(wA, wB);
const maxH = Math.max(hA, hB);
const aspectComp = maxW / maxH;
let fitWComp = availW;
let fitHComp = fitWComp / aspectComp;
if (fitHComp > availH) {
fitHComp = availH;
fitWComp = fitHComp * aspectComp;
}
fitWComp = Math.round(fitWComp);
fitHComp = Math.round(fitHComp);
if (isZoomed) {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${maxW}px`;
wrapper.style.height = `${maxH}px`;
wrapper.style.cursor = "zoom-out";
[mediaA, mediaB].forEach(el => {
if (el) {
@@ -542,24 +723,38 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
} else {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = "zoom-in";
wrapper.style.width = `${fitWComp}px`;
wrapper.style.height = `${fitHComp}px`;
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "calc(100vw - 120px)";
el.style.maxHeight = "calc(100vh - 120px)";
el.style.width = "auto";
el.style.height = "auto";
el.style.objectFit = "contain";
el.style.maxWidth = "none";
el.style.maxHeight = "none";
el.style.width = "100%";
el.style.height = "100%";
el.style.objectFit = "fill";
}
});
}
};
mediaA.onload = syncImageScales;
if (mediaA.complete) syncImageScales();
mediaA.onload = () => {
hidePlaceholder();
syncImageScales();
};
if (mediaA.complete && mediaA.naturalWidth) {
hidePlaceholder();
syncImageScales();
}
// Dynamically adjust preview scale if sidebars are resized
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (!isZoomed) syncImageScales();
});
ro.observe(scrollContainer);
overlay.addCleanup(() => ro.disconnect());
}
const toggleZoom = () => {
if (isBaseVideo) return;
@@ -585,20 +780,29 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
if (!isDraggingSlider) return;
const clientX = getClientX(e);
const clientY = getClientY(e);
if (!dragMoved && (Math.abs(clientX - dragStartX) > 4 || Math.abs(clientY - dragStartY) > 4)) {
// Distinguish click from drag: only snap when mouse moves more than 3px
if (!dragMoved && (Math.abs(clientX - dragStartX) > 3 || Math.abs(clientY - dragStartY) > 3)) {
dragMoved = true;
}
// Click + Drag: snap slider to mouse pointer and drag smoothly
if (dragMoved && mediaB) {
const rect = wrapper.getBoundingClientRect();
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
if (rect.width > 0) {
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
}
}
};
const endDrag = () => {
if (!isDraggingSlider) return;
isDraggingSlider = false;
if (dragMoved) setTimeout(() => { dragMoved = false; }, 50);
if (dragMoved) {
// Keep dragMoved true briefly so the upcoming click event doesn't trigger toggleZoom
setTimeout(() => { dragMoved = false; }, 60);
}
};
wrapper.addEventListener("mousedown", startDrag);
@@ -615,14 +819,17 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
window.removeEventListener("touchend", endDrag);
});
wrapper.addEventListener("click", () => {
wrapper.addEventListener("click", (e) => {
if (dragMoved || isBaseVideo) return;
toggleZoom();
});
scrollContainer.addEventListener("click", (e) => {
if (dragMoved) return;
if (e.target === scrollContainer || e.target === overlay.container) overlay.destroy();
if (e.target === scrollContainer || e.target === overlay.container) {
overlay.onUserClose?.();
overlay.destroy();
}
});
if (isBaseVideo) {
@@ -646,8 +853,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const isTargetText = typeof targetSrc === "object" && targetSrc.text;
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
const currentPid = activeComparisonViewer?.targetPid;
overlay.destroy();
showFullscreenPreview([targetSrc], isShiftClick);
showFullscreenPreview([targetSrc], isShiftClick, currentPid);
return;
}
if (!targetSrc) {
showPlaceholder();
return;
}
@@ -669,8 +882,10 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
mediaB = null;
}
slider.style.display = "none";
hidePlaceholder();
mediaA.src = targetSrc;
mediaA.onload = () => {
hidePlaceholder();
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
syncImageScales();
@@ -685,11 +900,23 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
if (!imgSrcs || imgSrcs.length === 0) return;
const item = imgSrcs[0];
const targetPid = pid || (typeof item === "object" && item ? item.pid : null);
const pidStr = targetPid ? String(targetPid) : null;
// Automatically manage Runtime Preview mode based on the card clicked
if (pidStr) {
if (isLatestOrActiveTask(pidStr)) {
if (!isRuntimePreviewActive) setRuntimePreviewEnabled(true, false);
} else {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
}
}
// Handle Text Output
if (typeof item === "object" && item.text) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isText) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(item);
return;
}
@@ -698,9 +925,10 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
}
activeComparisonViewer = createTextReader(
item,
(target) => showFullscreenPreview([target]),
(target) => showFullscreenPreview([target], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
@@ -710,6 +938,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
if (is3DFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.is3D) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src);
return;
}
@@ -718,9 +947,10 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
}
activeComparisonViewer = create3DViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc]),
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
@@ -728,6 +958,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
if (isAudioFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isAudio) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src);
return;
}
@@ -736,9 +967,10 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
}
activeComparisonViewer = createAudioViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc]),
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
@@ -748,6 +980,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
activeComparisonViewer.destroy();
activeComparisonViewer = null;
} else {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
@@ -758,6 +991,6 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null)
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) {
activeComparisonViewer.targetPid = pid ? String(pid) : null;
activeComparisonViewer.targetPid = pidStr;
}
}
+104 -33
View File
@@ -1,11 +1,17 @@
import { store } from "../core/store.js";
import { findActiveSidebars, findPropertiesPanel } from "../comfy/adapter.js";
export class SidebarOverlay {
constructor(options = {}) {
this.onDestroy = options.onDestroy || (() => {});
this.onUserClose = options.onUserClose || (() => {});
this.cleanupFns = [];
this.observedElements = new Set();
// Root container
// Elevate sidebars above overlay
document.body.classList.add("comfy-sidebar-overlay-active");
// Root container: visual backdrop (covers 100% flush from sidebar to sidebar, zero gap)
this.container = document.createElement("div");
this.container.className = `comfy-sidebar-comparison-overlay ${options.className || ""}`;
Object.assign(this.container.style, {
@@ -15,9 +21,23 @@ export class SidebarOverlay {
color: "var(--fg-color, #eee)",
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", zIndex: "1000", boxSizing: "border-box",
overflow: "hidden", pointerEvents: "auto", userSelect: "none"
overflow: "hidden", pointerEvents: "none", userSelect: "none"
});
// Interactive backdrop: insets 6px from each sidebar so the border zones pass clicks to the resizers
this.backdrop = document.createElement("div");
Object.assign(this.backdrop.style, {
position: "absolute", top: "0", bottom: "0", left: "6px", right: "6px",
pointerEvents: "auto", zIndex: "1"
});
this.backdrop.addEventListener("click", (e) => {
if (e.target === this.backdrop) {
this.onUserClose();
this.destroy();
}
});
this.container.appendChild(this.backdrop);
// Close button
this.closeBtn = document.createElement("span");
this.closeBtn.className = "pi pi-times";
@@ -29,7 +49,7 @@ export class SidebarOverlay {
transition: "all 0.15s ease",
background: "var(--comfy-input-bg, rgba(20, 20, 20, 0.6))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.1))",
borderRadius: "50%", padding: "6px"
borderRadius: "50%", padding: "6px", pointerEvents: "auto"
});
this.closeBtn.onmouseenter = () => {
this.closeBtn.style.color = "var(--fg-color, #fff)";
@@ -40,13 +60,19 @@ export class SidebarOverlay {
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
};
this.closeBtn.onclick = () => this.destroy();
this.closeBtn.onclick = () => {
this.onUserClose();
this.destroy();
};
this.container.appendChild(this.closeBtn);
// Backdrop click to close
this.container.addEventListener("click", (e) => {
if (e.target === this.container) this.destroy();
});
// ResizeObserver to track live drag-resizing on all active sidebars
if (window.ResizeObserver) {
this.ro = new ResizeObserver(() => {
this.updateOverlayBounds();
});
this.cleanupFns.push(() => this.ro.disconnect());
}
// Sidebar boundary alignment
this.updateOverlayBounds = this.updateOverlayBounds.bind(this);
@@ -54,17 +80,27 @@ export class SidebarOverlay {
window.addEventListener("resize", this.updateOverlayBounds);
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
if (sidebarEl && window.ResizeObserver) {
const ro = new ResizeObserver(this.updateOverlayBounds);
ro.observe(sidebarEl);
this.cleanupFns.push(() => ro.disconnect());
// Live observer for opening/closing panels or layout changes
if (window.MutationObserver) {
let updateScheduled = false;
const mo = new MutationObserver(() => {
if (!updateScheduled) {
updateScheduled = true;
requestAnimationFrame(() => {
this.updateOverlayBounds();
updateScheduled = false;
});
}
});
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "class"] });
this.cleanupFns.push(() => mo.disconnect());
}
// Global Key Handler (Esc to close)
const onKeyDown = (e) => {
if (e.key === "Escape") {
e.preventDefault();
this.onUserClose();
this.destroy();
}
if (options.onKeyDown) options.onKeyDown(e);
@@ -75,29 +111,62 @@ export class SidebarOverlay {
document.body.appendChild(this.container);
}
observeElement(el) {
if (!el || !this.ro || this.observedElements.has(el)) return;
this.observedElements.add(el);
this.ro.observe(el);
}
updateOverlayBounds() {
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
if (sidebarEl && sidebarEl.offsetWidth > 0 && sidebarEl.isConnected) {
const rect = sidebarEl.getBoundingClientRect();
if (rect.left < window.innerWidth / 2) {
const leftOffset = Math.max(0, rect.right);
this.container.style.left = `${leftOffset}px`;
this.container.style.width = `calc(100vw - ${leftOffset}px)`;
this.container.style.right = "0px";
} else {
const rightOffset = Math.max(0, window.innerWidth - rect.left);
this.container.style.left = "0px";
this.container.style.width = `calc(100vw - ${rightOffset}px)`;
this.container.style.right = `${rightOffset}px`;
let leftOffset = 0;
let rightOffset = 0;
let topOffset = 0;
const winW = window.innerWidth;
const midX = winW / 2;
try {
const { leftPanels, rightPanels } = findActiveSidebars();
[...leftPanels, ...rightPanels].forEach(p => this.observeElement(p));
for (const el of leftPanels) {
const rect = el.getBoundingClientRect();
if (rect.right > 0 && rect.right < midX) {
leftOffset = Math.max(leftOffset, Math.round(rect.right));
}
if (rect.top > 0 && rect.top < 120) {
topOffset = Math.max(topOffset, Math.round(rect.top));
}
}
this.container.style.top = `${Math.max(0, rect.top)}px`;
this.container.style.height = `calc(100vh - ${Math.max(0, rect.top)}px)`;
} else {
this.container.style.left = "0px";
this.container.style.width = "100vw";
this.container.style.top = "0px";
this.container.style.height = "100vh";
for (const el of rightPanels) {
const rect = el.getBoundingClientRect();
if (rect.left > midX && rect.left < winW) {
rightOffset = Math.max(rightOffset, Math.round(winW - rect.left));
}
if (rect.top > 0 && rect.top < 120) {
topOffset = Math.max(topOffset, Math.round(rect.top));
}
}
} catch (e) {
console.error("Comfy Sidebar: Error finding active sidebars:", e);
}
// Safety clamp: offsets can never exceed half screen width
leftOffset = Math.min(midX - 50, Math.max(0, leftOffset));
rightOffset = Math.min(midX - 50, Math.max(0, rightOffset));
topOffset = Math.max(0, topOffset);
this.container.style.left = `${leftOffset}px`;
this.container.style.right = `${rightOffset}px`;
this.container.style.width = `calc(100vw - ${leftOffset + rightOffset}px)`;
this.container.style.top = `${topOffset}px`;
this.container.style.height = `calc(100vh - ${topOffset}px)`;
// Crisp 1px visible separator lines along active sidebar borders
this.container.style.borderLeft = leftOffset > 0 ? "1px solid var(--border-color, rgba(255, 255, 255, 0.22))" : "none";
this.container.style.borderRight = rightOffset > 0 ? "1px solid var(--border-color, rgba(255, 255, 255, 0.22))" : "none";
}
addCleanup(fn) {
@@ -105,6 +174,8 @@ export class SidebarOverlay {
}
destroy() {
document.body.classList.remove("comfy-sidebar-overlay-active");
for (const fn of this.cleanupFns.splice(0)) {
try { fn(); } catch (e) {}
}
+59 -3
View File
@@ -30,7 +30,7 @@ export function injectStyles() {
display: flex !important;
}
/* Prevent action hover buttons (Delete/JSON/Node) from showing on pending & running cards */
/* Prevent action hover buttons from showing on pending & running cards */
.comfy-sidebar-card.pending .comfy-sidebar-hover-panel,
.comfy-sidebar-card.active .comfy-sidebar-hover-panel,
.comfy-sidebar-card.pending .comfy-sidebar-left-hover-panel,
@@ -41,7 +41,7 @@ export function injectStyles() {
.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;
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 10; color: #fff;
}
.comfy-sidebar-queue-cancel-btn {
@@ -57,6 +57,7 @@ export function injectStyles() {
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;
z-index: 25 !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
@@ -412,7 +413,7 @@ export function injectStyles() {
100% { transform: scaleY(1.3); }
}
/* Generic / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
/* Generic File Preview Cards */
.comfy-sidebar-file-wrapper {
width: 100%;
min-height: 88px;
@@ -512,6 +513,61 @@ export function injectStyles() {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
}
/* Elevate outer sidebar dock containers above the preview overlay */
body.comfy-sidebar-overlay-active aside,
body.comfy-sidebar-overlay-active nav,
body.comfy-sidebar-overlay-active .comfyui-sidebar,
body.comfy-sidebar-overlay-active .side-tool-bar-container,
body.comfy-sidebar-overlay-active .sidebar-content-container,
body.comfy-sidebar-overlay-active .comfyui-sidebar-content,
body.comfy-sidebar-overlay-active .p-sidebar,
body.comfy-sidebar-overlay-active .p-sidebar-right,
body.comfy-sidebar-overlay-active [data-testid="properties-panel"],
body.comfy-sidebar-overlay-active [data-testid="workflow-overview-panel"],
body.comfy-sidebar-overlay-active [class*="properties-panel"],
body.comfy-sidebar-overlay-active [class*="resize-handle"],
body.comfy-sidebar-overlay-active [class*="resizer"],
body.comfy-sidebar-overlay-active [class*="splitter"],
body.comfy-sidebar-overlay-active [class*="gutter"],
body.comfy-sidebar-overlay-active [role="separator"] {
z-index: 1005 !important;
}
/* Explicit card element layering so badges and [X] are ALWAYS on top of images */
.comfy-sidebar-card .comfy-sidebar-media-wrapper {
z-index: 1 !important;
}
.comfy-sidebar-card-timer {
z-index: 10 !important;
}
.comfy-sidebar-queue-cancel-btn {
z-index: 25 !important;
}
.comfy-sidebar-status-badge {
z-index: 30 !important;
}
/* Slim down the Queue sidebar scrollbar so it stops colliding with the separator */
.comfyui-sidebar ::-webkit-scrollbar,
.comfy-sidebar ::-webkit-scrollbar,
[class*="sidebar-content"] ::-webkit-scrollbar,
.sidebar-content-container ::-webkit-scrollbar {
width: 6px !important;
}
.comfyui-sidebar ::-webkit-scrollbar-thumb,
.comfy-sidebar ::-webkit-scrollbar-thumb,
[class*="sidebar-content"] ::-webkit-scrollbar-thumb,
.sidebar-content-container ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.25) !important;
border-radius: 3px !important;
}
/* Smooth animation for the circular loader */
@keyframes comfy-sidebar-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
+32 -8
View File
@@ -208,25 +208,48 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex",
flexDirection: "column", gap: "14px", zIndex: "20",
color: "var(--fg-color, #eee)"
color: "var(--fg-color, #eee)",
pointerEvents: "auto"
});
const formatTitle = (id) => {
if (!id) return "Text Output";
const shortId = id.length > 10 ? `${id.slice(0, 8)}…` : id;
return `Text Output #${shortId}`;
};
const headerRow = document.createElement("div");
Object.assign(headerRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px" });
Object.assign(headerRow.style, {
display: "flex", alignItems: "center", justifyContent: "space-between",
borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px",
flexWrap: "wrap", gap: "10px"
});
const titleInfo = document.createElement("div");
Object.assign(titleInfo.style, { display: "flex", alignItems: "center", gap: "10px" });
Object.assign(titleInfo.style, {
display: "flex", alignItems: "center", gap: "10px",
minWidth: "0", flex: "1 1 auto", overflow: "hidden"
});
const title = document.createElement("span");
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f8fafc", whiteSpace: "nowrap" });
title.textContent = formatTitle(pid);
title.title = pid ? `Text Output #${pid}` : "Text Output";
Object.assign(title.style, {
fontSize: "14px", fontWeight: "bold", color: "#f8fafc",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: "1"
});
const statsBadge = document.createElement("span");
Object.assign(statsBadge.style, { fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)", padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace", whiteSpace: "nowrap" });
Object.assign(statsBadge.style, {
fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)",
padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace",
whiteSpace: "nowrap", flexShrink: "0"
});
titleInfo.append(title, statsBadge);
const actionsRow = document.createElement("div");
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center" });
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" });
const modeBtn = document.createElement("button");
Object.assign(modeBtn.style, {
@@ -328,7 +351,8 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
}
rawText = targetData.text || "";
pid = targetData.pid || "";
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
title.textContent = formatTitle(pid);
title.title = pid ? `Text Output #${pid}` : "Text Output";
updateView();
},
destroy: () => overlay.destroy()
+4 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "comfy-sidebar"
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
version = "1.6.2"
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
version = "1.7.0"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",
@@ -12,4 +12,5 @@ Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
[tool.comfy]
PublisherId = "soundslikethunder"
DisplayName = "Comfy Sidebar"
DisplayName = "Comfy Sidebar"
Icon = "https://raw.githubusercontent.com/m0rtus59/ComfyUI-ComfySidebar/main/icon.png"