Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa512912e | ||
|
|
65900536ce | ||
|
|
7dbde17bcd | ||
|
|
a63c7d2afd | ||
|
|
3ccd3b6a7c | ||
|
|
735cac9c97 | ||
|
|
a9575f1a1c | ||
|
|
984cd108d6 | ||
|
|
ce2379b35c | ||
|
|
2a975bdc9f |
@@ -11,8 +11,10 @@ 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.
|
||||
* **[NEW]** 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.
|
||||
* **[NEW]** Added toggles to hide buttons you don't need from the side panel.
|
||||
|
||||
<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
|
||||
|
||||
Vendored
+66
-56
@@ -1,5 +1,4 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { State } from "./state.js";
|
||||
|
||||
async function uploadDroppedImageToInput(imageObj) {
|
||||
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
|
||||
@@ -22,72 +21,83 @@ async function uploadDroppedImageToInput(imageObj) {
|
||||
|
||||
export function setupDragAndDrop() {
|
||||
document.addEventListener("dragover", (e) => {
|
||||
if (State.currentDraggedImgData) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
|
||||
document.addEventListener("drop", async (e) => {
|
||||
if (!State.currentDraggedImgData) return;
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (canvas && canvas.graph) {
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
if (!canvas || !canvas.graph) return;
|
||||
|
||||
let droppedOnImageNode = false;
|
||||
|
||||
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
|
||||
if (State.currentDraggedImgData.filename) {
|
||||
// 1. Check for standard workflow JSON data drop
|
||||
const jsonStr = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const workflow = JSON.parse(jsonStr);
|
||||
if (workflow && workflow.nodes) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
droppedOnImageNode = true;
|
||||
const widget = targetNode.widgets.find(w => w.name === "image");
|
||||
if (widget) {
|
||||
const newFilename = await uploadDroppedImageToInput(State.currentDraggedImgData);
|
||||
if (newFilename) {
|
||||
widget.value = newFilename;
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(workflow);
|
||||
} else if (app.handleFile) {
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// 2. Check for native image URL drop
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname === "/view") {
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
|
||||
if (filename) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
|
||||
// Dropped on a node with image input: Copy/Upload image to inputs
|
||||
const widget = targetNode.widgets.find(w => w.name === "image");
|
||||
if (widget) {
|
||||
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
widget.value = newFilename;
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas: Load workflow natively
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename || "workflow.png", { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!droppedOnImageNode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (State.currentDraggedImgData.workflow) {
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(State.currentDraggedImgData.workflow);
|
||||
} else if (app.handleFile) {
|
||||
const jsonStr = JSON.stringify(State.currentDraggedImgData.workflow);
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
} else if (State.currentDraggedImgData.filename) {
|
||||
const src = State.currentDraggedImgData.url || `/view?filename=${encodeURIComponent(State.currentDraggedImgData.filename)}&type=${State.currentDraggedImgData.type || 'output'}&subfolder=${encodeURIComponent(State.currentDraggedImgData.subfolder || '')}`;
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], State.currentDraggedImgData.filename || "workflow.png", { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to synthesize workflow file drop:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
}
|
||||
}
|
||||
State.currentDraggedImgData = null;
|
||||
}, true);
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
// layout.js - Fully Fixed & Renamed to 'Hide Junk'
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
|
||||
[class*="actionbar"]:not(.actionbar),
|
||||
[class*="actionbar-buttons"],
|
||||
.actionbar-buttons {
|
||||
position: fixed !important;
|
||||
top: -5px !important;
|
||||
right: 4px !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
z-index: 1010 !important;
|
||||
|
||||
/* Nuke the background rectangle, border, and shadows */
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 150px !important;
|
||||
padding-right: 580px !important;
|
||||
}
|
||||
|
||||
/* Medium desktop screens (between 1200px and 1599px width) */
|
||||
@media (max-width: 1599px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 100px !important;
|
||||
padding-right: 420px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 40px !important;
|
||||
padding-right: 280px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 180px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function applyClassicLayout(enable) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (app.ui && app.ui.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
|
||||
if (app.ui && app.ui.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Properties Panel Toggle Corrections --- */
|
||||
|
||||
let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
|
||||
if (icon) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig && orig.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("queue") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
|
||||
return !!(icon && icon.className.includes("lucide--panel-left"));
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Direct mapping from tab settings to their unique class-based HTML icon tags
|
||||
const tabIconSelectors = {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"NodesMap": '[class*="pi-sitemap"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
|
||||
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
|
||||
// Read from renamed "Hide Junk" path
|
||||
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
|
||||
|
||||
const icon = sidebar.querySelector(selector);
|
||||
if (icon) {
|
||||
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
|
||||
|
||||
if (shouldHide) {
|
||||
if (tabBtn.style.display !== "none") {
|
||||
tabBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (tabBtn.style.display === "none") {
|
||||
tabBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle "Override Stock Job History Tab" using its renamed setting path
|
||||
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
|
||||
if (historyIcon) {
|
||||
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
|
||||
if (hideStockHistory) {
|
||||
if (historyBtn.style.display !== "none") {
|
||||
historyBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (historyBtn.style.display === "none") {
|
||||
historyBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
// 1. Target elements with Tailwind classes, but IGNORE those inside modal settings/sidebars
|
||||
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
|
||||
for (const el of elements) {
|
||||
// Filter out dialogs, sidebar overlays, settings drawers, and modals
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
|
||||
continue;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// 2. Same fallback logic, excluding modals
|
||||
const fallbacks = document.querySelectorAll('button, .bg-secondary-background');
|
||||
for (const el of fallbacks) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
|
||||
continue;
|
||||
}
|
||||
const text = (el.textContent || el.getAttribute("title") || "").toLowerCase();
|
||||
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function syncClassicLayout() {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
|
||||
// 1. Handle "Hide Graph Button" Toggle under the renamed "Hide Junk" path
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") {
|
||||
graphBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") {
|
||||
graphBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Exit early and clean up overrides if Classic Topbar layout is off
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
if (originalBtn && originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) {
|
||||
customBtn.remove();
|
||||
}
|
||||
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator && indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Handle Active Layout elements synchronization
|
||||
const originalBtn = findOriginalButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = savedButtonData.innerHTML;
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(customBtn.style, {
|
||||
position: "relative",
|
||||
zIndex: "1000",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
opacity: "1",
|
||||
pointerEvents: "auto",
|
||||
display: "inline-flex"
|
||||
});
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
if (nativeBtn) nativeBtn.click();
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
if (customBtn) {
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
// 4. Handle "0 active" indicator visibility based on history tab settings
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator) {
|
||||
if (hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") {
|
||||
indicator.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
}
|
||||
|
||||
export function setupPropertiesPanelToggleFix() {
|
||||
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-layout-fix-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle {
|
||||
order: 99999 !important;
|
||||
margin-left: -8px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
|
||||
order: 99999 !important;
|
||||
margin-left: 0px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
}
|
||||
|
||||
syncClassicLayout();
|
||||
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldSync = false;
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
|
||||
// Ensure mutation is on an actual Element node (nodeType 1) and not just text node updates
|
||||
const hasElementMutation = Array.from(mutation.addedNodes).some(node => node.nodeType === 1) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1);
|
||||
|
||||
if (!hasElementMutation) continue;
|
||||
|
||||
// Ignore mutations on our own components to avoid feedback loops
|
||||
const isOurNode = Array.from(mutation.addedNodes).some(node =>
|
||||
node.classList?.contains?.("comfy-sidebar-custom-properties-toggle") ||
|
||||
node.id === "comfy-sidebar-layout-fix-styles"
|
||||
);
|
||||
if (isOurNode) continue;
|
||||
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldSync && !syncScheduled) {
|
||||
syncScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
syncClassicLayout();
|
||||
syncScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
+32
-2
@@ -80,6 +80,7 @@ const concludeRun = async (pid, statusStr) => {
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[pid]) {
|
||||
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
|
||||
st.nodeOutputs = hItem[pid].outputs; // Store full outputs dictionary!
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs);
|
||||
st.texts = findTextsInOutputs(hItem[pid].outputs);
|
||||
}
|
||||
@@ -92,6 +93,20 @@ export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
// Auto-sweep cancelled and errored jobs if setting is enabled
|
||||
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
|
||||
const toDelete = [];
|
||||
for (const [p, s] of promptStates.entries()) {
|
||||
if (s.status === "cancelled" || s.status === "error") {
|
||||
toDelete.push(p);
|
||||
promptStates.delete(p);
|
||||
}
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
const pid = e.detail.prompt_id;
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
@@ -108,6 +123,7 @@ export function setupApiListeners() {
|
||||
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
|
||||
});
|
||||
}
|
||||
renderDOMFn(); // Render immediately to prevent pop-in delay!
|
||||
syncQueue();
|
||||
});
|
||||
|
||||
@@ -148,9 +164,22 @@ export function setupApiListeners() {
|
||||
|
||||
api.addEventListener("executed", (e) => {
|
||||
if (promptStates.has(e.detail.prompt_id)) {
|
||||
const st = promptStates.get(e.detail.prompt_id);
|
||||
const finalImgs = findImagesInOutputs({ output: e.detail.output });
|
||||
if (finalImgs.length > 0) promptStates.get(e.detail.prompt_id).images = finalImgs;
|
||||
promptStates.get(e.detail.prompt_id).texts = findTextsInOutputs({ output: e.detail.output });
|
||||
if (finalImgs.length > 0) {
|
||||
// Overwrite with latest images to avoid accumulation across sequential nodes
|
||||
st.images = finalImgs;
|
||||
}
|
||||
const finalTexts = findTextsInOutputs({ output: e.detail.output });
|
||||
if (finalTexts.length > 0) {
|
||||
// Overwrite with latest texts to avoid clearing previously set text outputs
|
||||
st.texts = finalTexts;
|
||||
}
|
||||
|
||||
// Dynamically capture each executed node's individual outputs
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
st.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
@@ -192,6 +221,7 @@ export async function initSessionAndHistory() {
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(id, {
|
||||
pid: id, status: "completed", images, texts,
|
||||
nodeOutputs: historyData[id].outputs, // Store full outputs dictionary!
|
||||
workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null,
|
||||
progressText: "", timestamp: State.globalOrderCounter, rendered: true
|
||||
});
|
||||
|
||||
+65
-1
@@ -1,23 +1,87 @@
|
||||
// sidebar.js - Fully Fixed Real-Time UI Sync (With setTimeout deferred updates)
|
||||
import { app } from "/scripts/app.js";
|
||||
import { injectStyles } from "./styles.js";
|
||||
import { setupDragAndDrop } from "./dragdrop.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
// Standard Comfy Queue Settings
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Override Stock Job History Tab", name: "Replaces the stock Job History sidebar with Comfy Queue", type: "boolean", defaultValue: false });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
|
||||
// Add settings to hide individual sidebar tabs under the "Hide Junk" sub-category
|
||||
// We use setTimeout to let Comfy finish writing the setting value before reading it
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "NodesMap", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Group the Stock History Tab override into "Hide Junk"
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replaces the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the toggle setting for Graph Button under "Hide Junk"
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the toggle setting for Classic / Comfy Layout
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
applyClassicLayout(value);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
// Apply saved layout configuration on startup
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled);
|
||||
|
||||
// Run properties panel toggle correction
|
||||
setupPropertiesPanelToggleFix();
|
||||
|
||||
// Safely resolve circular dependencies using Dependency Injection Hooks
|
||||
setSyncQueue(syncQueue);
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
+4
-2
@@ -3,12 +3,13 @@ export const cardElements = new Map();
|
||||
|
||||
// Global shared variables
|
||||
export const State = {
|
||||
currentDraggedImgData: null,
|
||||
currentSearchQuery: "",
|
||||
globalOrderCounter: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null,
|
||||
currentlyActivePromptId: null
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
|
||||
activeSubmenuBatchImages: null // Active batch images explorer pointer
|
||||
};
|
||||
|
||||
export function pruneHistory(app) {
|
||||
@@ -39,6 +40,7 @@ export function saveStatesToLocalStorage() {
|
||||
|
||||
serializable.push({
|
||||
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs, // Persist node level outputs
|
||||
workflow: state.workflow, progress: state.progress || 0, queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "", timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "", rendered: state.rendered || false,
|
||||
|
||||
+37
-4
@@ -1,3 +1,4 @@
|
||||
// styles.js
|
||||
export function injectStyles() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
@@ -6,22 +7,54 @@ export function injectStyles() {
|
||||
border-radius: 4px; padding: 8px; position: relative;
|
||||
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
border: 2px solid var(--border-color, #333);
|
||||
color: var(--comfy-input-color, var(--fg-color, #eee));
|
||||
}
|
||||
.comfy-sidebar-card:hover { border-color: var(--p-primary-color, var(--primary-color, #555)) !important; }
|
||||
.comfy-sidebar-card:hover {
|
||||
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
|
||||
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
|
||||
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
|
||||
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
|
||||
.comfy-sidebar-card.pending .pi-times { display: none !important; }
|
||||
.comfy-sidebar-card.pending:hover .pi-times { display: flex !important; }
|
||||
|
||||
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card-timer {
|
||||
position: absolute; top: 6px; left: 8px; font-size: 10px;
|
||||
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
|
||||
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
|
||||
}
|
||||
|
||||
/* --- Custom Queue Cancel Button to Mimic ComfyUI Native Destructive Button --- */
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
display: none;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
background-color: #7f1d1d !important; /* Native deep red */
|
||||
color: #e2e8f0 !important; /* Native slate white icon */
|
||||
font-size: 11px !important; /* Muted native cross size */
|
||||
border-radius: 6px !important; /* Native corner rounding */
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn:hover {
|
||||
background-color: #991b1b !important; /* Slightly brighter red on hover */
|
||||
color: #ffffff !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
|
||||
import { isVideoFormat, matchesFilter } from "./utils.js";
|
||||
import { isVideoFormat, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
|
||||
// DI Hook for cyclic imports
|
||||
export let syncQueueFn = async () => {};
|
||||
@@ -85,7 +85,7 @@ export const findStandardQueueButton = () => {
|
||||
};
|
||||
|
||||
export const applySidebarOverride = () => {
|
||||
const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Override Stock Job History Tab") ?? false;
|
||||
const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const stdBtn = findStandardQueueButton();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
|
||||
@@ -146,45 +146,70 @@ export function setupSidebarUI() {
|
||||
titleGroup.appendChild(title);
|
||||
standardHeader.appendChild(titleGroup);
|
||||
|
||||
const clearHistoryBtn = document.createElement("button");
|
||||
clearHistoryBtn.textContent = "Clear History";
|
||||
Object.assign(clearHistoryBtn.style, {
|
||||
background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)",
|
||||
borderRadius: "3px", padding: "3px 8px", cursor: "pointer", fontSize: "11px", fontWeight: "bold", transition: "all 0.15s ease-in-out"
|
||||
});
|
||||
// Dynamic Compact Actions Group
|
||||
const actionsGroup = document.createElement("div");
|
||||
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
|
||||
|
||||
let clearHistoryTimeout = null, isClearHistoryPending = false;
|
||||
const resetClearHistoryBtn = () => {
|
||||
isClearHistoryPending = false;
|
||||
clearHistoryBtn.textContent = "Clear History";
|
||||
Object.assign(clearHistoryBtn.style, { color: "var(--desc-color, #aaa)", background: "transparent", borderColor: "var(--border-color, #555)", boxShadow: "none" });
|
||||
if (clearHistoryTimeout) { clearTimeout(clearHistoryTimeout); clearHistoryTimeout = null; }
|
||||
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = iconClass;
|
||||
btn.title = tooltip;
|
||||
Object.assign(btn.style, {
|
||||
background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)",
|
||||
borderRadius: "3px", padding: "4px 8px", cursor: "pointer", fontSize: "13px", transition: "all 0.15s ease-in-out"
|
||||
});
|
||||
|
||||
let timeout = null, isPending = false;
|
||||
const reset = () => {
|
||||
isPending = false;
|
||||
Object.assign(btn.style, { color: "var(--desc-color, #aaa)", background: "transparent", borderColor: "var(--border-color, #555)", boxShadow: "none" });
|
||||
if (timeout) { clearTimeout(timeout); timeout = null; }
|
||||
};
|
||||
|
||||
btn.onmouseenter = () => { if (!isPending) { btn.style.borderColor = "var(--fg-color, #eee)"; btn.style.color = "var(--fg-color, #eee)"; } };
|
||||
btn.onmouseleave = () => { if (!isPending) { btn.style.borderColor = "var(--border-color, #555)"; btn.style.color = "var(--desc-color, #aaa)"; } };
|
||||
|
||||
btn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!isPending) {
|
||||
isPending = true;
|
||||
Object.assign(btn.style, { color: "#fff", background: hoverColor, borderColor: hoverColor, boxShadow: `0 0 8px ${hoverColor}80` });
|
||||
timeout = setTimeout(reset, 1500); // 1.5s to confirm
|
||||
} else {
|
||||
reset();
|
||||
await onClickFn();
|
||||
}
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
clearHistoryBtn.addEventListener("mouseenter", () => {
|
||||
if (!isClearHistoryPending) { clearHistoryBtn.style.borderColor = "var(--fg-color, #eee)"; clearHistoryBtn.style.color = "var(--fg-color, #eee)"; }
|
||||
});
|
||||
clearHistoryBtn.addEventListener("mouseleave", () => {
|
||||
if (!isClearHistoryPending) { clearHistoryBtn.style.borderColor = "var(--border-color, #555)"; clearHistoryBtn.style.color = "var(--desc-color, #aaa)"; }
|
||||
});
|
||||
|
||||
clearHistoryBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!isClearHistoryPending) {
|
||||
isClearHistoryPending = true;
|
||||
clearHistoryBtn.textContent = "Confirm?";
|
||||
Object.assign(clearHistoryBtn.style, { color: "#fff", background: "#dc3545", borderColor: "#dc3545", boxShadow: "0 0 8px rgba(220, 53, 69, 0.6)" });
|
||||
clearHistoryTimeout = setTimeout(resetClearHistoryBtn, 1500);
|
||||
} else {
|
||||
resetClearHistoryBtn();
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
if (state.status !== "pending" && state.status !== "active") { promptStates.delete(pid); cardElements.delete(pid); }
|
||||
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
|
||||
const toDelete = [];
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
if (state.status === "cancelled" || state.status === "error") {
|
||||
toDelete.push(pid);
|
||||
promptStates.delete(pid);
|
||||
}
|
||||
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clear: true }) }); } catch (err) {}
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }); } catch (err) {}
|
||||
renderDOM();
|
||||
}
|
||||
};
|
||||
standardHeader.appendChild(clearHistoryBtn);
|
||||
});
|
||||
|
||||
const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => {
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
if (state.status !== "pending" && state.status !== "active") {
|
||||
promptStates.delete(pid);
|
||||
}
|
||||
}
|
||||
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clear: true }) }); } catch (err) {}
|
||||
renderDOM();
|
||||
});
|
||||
|
||||
actionsGroup.appendChild(btnClearInterrupted);
|
||||
actionsGroup.appendChild(btnClearAll);
|
||||
standardHeader.appendChild(actionsGroup);
|
||||
|
||||
const searchContainer = document.createElement("div");
|
||||
Object.assign(searchContainer.style, { display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)", border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px", boxSizing: "border-box", height: "26px" });
|
||||
@@ -213,6 +238,19 @@ export function setupSidebarUI() {
|
||||
Object.assign(State.cardStack.style, { flex: "1", overflowY: "auto", scrollbarWidth: "thin", display: "block" });
|
||||
State.sidebarContainer.appendChild(State.cardStack);
|
||||
|
||||
// Global background click handler: return to main queue if clicking empty space of the entire container
|
||||
State.sidebarContainer.onclick = (e) => {
|
||||
if (State.activeSubmenuPromptId || State.activeSubmenuBatchImages) {
|
||||
// Stop closure if we clicked on standard interactive elements (images, buttons, spans, cancels)
|
||||
if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return;
|
||||
State.activeSubmenuPromptId = null;
|
||||
State.activeSubmenuBatchImages = null;
|
||||
renderDOM();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
}
|
||||
};
|
||||
|
||||
new ResizeObserver((entries) => {
|
||||
const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350;
|
||||
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
|
||||
@@ -232,6 +270,254 @@ export function setupSidebarUI() {
|
||||
return State.sidebarContainer;
|
||||
}
|
||||
|
||||
// Unified image batch pagination renderer (supports standard queue cards & both explorer submenus)
|
||||
function renderCardImages(cardObj, state, keepAspect) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
}
|
||||
|
||||
const idx = cardObj.currentImageIndex;
|
||||
const img = state.images[idx];
|
||||
if (!img) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const isVideo = isVideoFormat(src);
|
||||
|
||||
const currentMediaEl = cardObj.grid.querySelector("img, video");
|
||||
const matchesTagType = currentMediaEl && (isVideo === (currentMediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
if (matchesTagType) {
|
||||
// --- IN-PLACE REUSE PATH (ELIMINATES BLINKING & SHAPE-SHIFTS) ---
|
||||
const mediaEl = currentMediaEl;
|
||||
|
||||
// 1. Update interactive event listeners on reuse to bind correct closure variables
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview(state.images.map(i => i.url ? i.url : window.location.origin + `/view?filename=${encodeURIComponent(i.filename)}&type=${encodeURIComponent(i.type || 'output')}&subfolder=${encodeURIComponent(i.subfolder || '')}`));
|
||||
};
|
||||
|
||||
if (!isVideo) {
|
||||
// Re-bind native drag handlers with updated state values
|
||||
const newDragStart = (e) => {
|
||||
State.currentDraggedImgData = { ...img, workflow: state.workflow };
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", src);
|
||||
e.dataTransfer.setData("text/plain", src);
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.stopPropagation();
|
||||
};
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
mediaEl.addEventListener("dragstart", newDragStart);
|
||||
mediaEl._currentDragStart = newDragStart;
|
||||
}
|
||||
|
||||
// 2. Perform smooth in-memory source swapping for blob live previews
|
||||
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
|
||||
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
|
||||
if (src.startsWith("blob:") && !isVideo) {
|
||||
// Smooth preload trick: hold current preview until the new one is fully loaded
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = () => {
|
||||
const oldBlob = mediaEl._lastBlob;
|
||||
mediaEl.src = src;
|
||||
mediaEl._lastBlob = src;
|
||||
|
||||
if (keepAspect && tempImg.naturalWidth && tempImg.naturalHeight) {
|
||||
mediaEl.style.aspectRatio = `${tempImg.naturalWidth} / ${tempImg.naturalHeight}`;
|
||||
}
|
||||
if (cardObj.dimEl && tempImg.naturalWidth && tempImg.naturalHeight) {
|
||||
cardObj.dimEl.textContent = `${tempImg.naturalWidth}x${tempImg.naturalHeight}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
|
||||
if (oldBlob && oldBlob !== src) {
|
||||
try { URL.revokeObjectURL(oldBlob); } catch(e){}
|
||||
}
|
||||
};
|
||||
tempImg.src = src;
|
||||
} else {
|
||||
// Direct fast swap for standard server files
|
||||
mediaEl.src = isVideo ? src + "#t=0.001" : src;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update the pagination label if it exists
|
||||
const label = cardObj.grid.querySelector(".comfy-sidebar-batch-label");
|
||||
if (label) {
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- REBUILD PATH (ONLY RUNS ON TYPE CHANGE OR FIRST RENDER) ---
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
|
||||
|
||||
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
Object.assign(thumb.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element"
|
||||
});
|
||||
|
||||
if (isVideo) {
|
||||
thumb.muted = true;
|
||||
thumb.playsInline = true;
|
||||
thumb.preload = "metadata";
|
||||
thumb.loop = true;
|
||||
|
||||
const playIcon = document.createElement("div");
|
||||
Object.assign(playIcon.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
pointerEvents: "none", zIndex: "5", transition: "opacity 0.2s ease"
|
||||
});
|
||||
|
||||
playIcon.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
|
||||
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
|
||||
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
|
||||
</svg>
|
||||
`;
|
||||
wrapper.appendChild(playIcon);
|
||||
|
||||
thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
|
||||
thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
|
||||
}
|
||||
|
||||
if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
|
||||
|
||||
if (!isVideo) {
|
||||
thumb.setAttribute("draggable", "true");
|
||||
thumb.onload = () => {
|
||||
if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) {
|
||||
thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`;
|
||||
}
|
||||
if (cardObj.dimEl) {
|
||||
cardObj.dimEl.textContent = `${thumb.naturalWidth}x${thumb.naturalHeight}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
thumb.src = src;
|
||||
|
||||
const dragStartHandler = (e) => {
|
||||
State.currentDraggedImgData = { ...img, workflow: state.workflow };
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", src);
|
||||
e.dataTransfer.setData("text/plain", src);
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.stopPropagation();
|
||||
};
|
||||
thumb.addEventListener("dragstart", dragStartHandler);
|
||||
thumb._currentDragStart = dragStartHandler;
|
||||
} else {
|
||||
thumb.setAttribute("draggable", "false");
|
||||
thumb.onloadedmetadata = () => {
|
||||
if (keepAspect && thumb.videoWidth && thumb.videoHeight) {
|
||||
thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`;
|
||||
}
|
||||
if (cardObj.dimEl) {
|
||||
cardObj.dimEl.textContent = `${thumb.videoWidth}x${thumb.videoHeight}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
thumb.src = src + "#t=0.001";
|
||||
}
|
||||
|
||||
thumb.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview(state.images.map(i => i.url ? i.url : window.location.origin + `/view?filename=${encodeURIComponent(i.filename)}&type=${encodeURIComponent(i.type || 'output')}&subfolder=${encodeURIComponent(i.subfolder || '')}`));
|
||||
};
|
||||
|
||||
wrapper.insertBefore(thumb, wrapper.firstChild);
|
||||
cardObj.firstImgElement = thumb;
|
||||
|
||||
// Overlay slick pagination controller bar if batch has multiple output images
|
||||
if (state.images.length > 1) {
|
||||
const navBar = document.createElement("div");
|
||||
Object.assign(navBar.style, {
|
||||
position: "absolute", bottom: "6px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
|
||||
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
|
||||
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.4)"
|
||||
});
|
||||
|
||||
const prevBtn = document.createElement("span");
|
||||
prevBtn.className = "pi pi-chevron-left";
|
||||
prevBtn.style.cursor = "pointer";
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "comfy-sidebar-batch-label";
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
label.style.cursor = "pointer";
|
||||
label.title = "View all images in this batch";
|
||||
|
||||
// Open the dynamic batch explorer explorer view when the middle counter label is clicked
|
||||
label.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
State.activeSubmenuBatchImages = {
|
||||
pid: state.pid || cardObj.element.id.replace("card-", ""),
|
||||
images: state.images,
|
||||
workflow: state.workflow
|
||||
};
|
||||
renderDOM();
|
||||
};
|
||||
|
||||
const nextBtn = document.createElement("span");
|
||||
nextBtn.className = "pi pi-chevron-right";
|
||||
nextBtn.style.cursor = "pointer";
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
wrapper.appendChild(navBar);
|
||||
}
|
||||
|
||||
cardObj.grid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
let globalClickRegistered = false;
|
||||
|
||||
// Global document mousedown-capturer to reliably trigger click-aways outside the sidebar
|
||||
const handleGlobalClick = (e) => {
|
||||
if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
return;
|
||||
}
|
||||
const sidebar = State.sidebarContainer;
|
||||
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 10000"]');
|
||||
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
State.activeSubmenuBatchImages = null;
|
||||
renderDOM();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
}
|
||||
};
|
||||
|
||||
let renderTimeout = null;
|
||||
export function renderDOM() {
|
||||
if (renderTimeout) cancelAnimationFrame(renderTimeout);
|
||||
@@ -240,6 +526,244 @@ export function renderDOM() {
|
||||
const keepAspect = app.ui.settings.getSettingValue("Comfy Sidebar.Keep Object Aspect Ratio") ?? true;
|
||||
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
|
||||
const headerTitle = State.sidebarContainer.querySelector("h3");
|
||||
const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
|
||||
const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
|
||||
|
||||
const styleRightBtn = (btn) => {
|
||||
Object.assign(btn.style, {
|
||||
display: "inline-flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
width: "32px", height: "32px",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "14px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)"
|
||||
});
|
||||
btn.onmouseenter = () => { btn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; btn.style.color = "#fff"; };
|
||||
btn.onmouseleave = () => { btn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; btn.style.color = "#e2e8f0"; };
|
||||
};
|
||||
|
||||
// --- SUBMENU 2: BATCH IMAGES EXPLORER ---
|
||||
if (State.activeSubmenuBatchImages) {
|
||||
const batchInfo = State.activeSubmenuBatchImages;
|
||||
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back to Queue";
|
||||
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; renderDOM(); };
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
|
||||
if (!globalClickRegistered) {
|
||||
document.addEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const targetElements = [];
|
||||
|
||||
batchInfo.images.forEach((img, index) => {
|
||||
const cardId = `batch-${batchInfo.pid}-${index}`;
|
||||
let cardObj = cardElements.get(cardId);
|
||||
|
||||
if (!cardObj) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "comfy-sidebar-card completed";
|
||||
card.style.position = "relative";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
timerEl.textContent = `Image ${index + 1}/${batchInfo.images.length}`;
|
||||
timerEl.style.display = "block";
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
Object.assign(dimEl.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
|
||||
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
|
||||
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
|
||||
display: "none"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
|
||||
// Symmetrical hover panel on the right with ONLY the Download Object button
|
||||
const hoverPanel = document.createElement("div");
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image";
|
||||
btnImg.title = "Download Object";
|
||||
styleRightBtn(btnImg);
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
|
||||
cardElements.set(cardId, cardObj);
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
cardObj.placeholder.style.display = "none";
|
||||
|
||||
cardObj.btnImg.style.display = "inline-flex";
|
||||
cardObj.btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
};
|
||||
|
||||
renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow }, keepAspect);
|
||||
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
|
||||
targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
|
||||
while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- SUBMENU 1: WORKFLOW OUTPUTS EXPLORER ---
|
||||
if (State.activeSubmenuPromptId) {
|
||||
const st = promptStates.get(State.activeSubmenuPromptId);
|
||||
if (!st) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
renderDOM();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync Header UI State for the explorer view
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = `Outputs of #${State.activeSubmenuPromptId}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back to Queue";
|
||||
headerTitle.onclick = () => { State.activeSubmenuPromptId = null; renderDOM(); };
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
|
||||
if (!globalClickRegistered) {
|
||||
document.addEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const outputs = getRunOutputs(st.nodeOutputs);
|
||||
const targetElements = [];
|
||||
|
||||
outputs.forEach((out) => {
|
||||
const cardId = `submenu-${st.pid}-${out.nodeId}`;
|
||||
let cardObj = cardElements.get(cardId);
|
||||
|
||||
if (!cardObj) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "comfy-sidebar-card completed";
|
||||
card.style.position = "relative";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId));
|
||||
timerEl.textContent = node ? (node.title || node.type) : `Node #${out.nodeId}`;
|
||||
timerEl.style.display = "block";
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
Object.assign(dimEl.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
|
||||
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
|
||||
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
|
||||
display: "none"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
|
||||
// Action overlay on hover containing only Download Object
|
||||
const hoverPanel = document.createElement("div");
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image";
|
||||
btnImg.title = "Download Object";
|
||||
styleRightBtn(btnImg);
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
|
||||
cardElements.set(cardId, cardObj);
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
if (out.images && out.images.length > 0) {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
|
||||
cardObj.btnImg.style.display = "inline-flex";
|
||||
cardObj.btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
out.images.forEach(img => {
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
|
||||
renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow }, keepAspect);
|
||||
} else {
|
||||
cardObj.btnImg.style.display = "none";
|
||||
cardObj.placeholder.style.display = "block";
|
||||
cardObj.placeholder.textContent = "No Outputs";
|
||||
}
|
||||
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
|
||||
targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
|
||||
while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- STANDARD QUEUE RENDER LOGIC ---
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = "Queue";
|
||||
headerTitle.style.cursor = "default";
|
||||
headerTitle.onclick = null;
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "inline";
|
||||
if (headerActions) headerActions.style.display = "flex";
|
||||
|
||||
let tasksArray = Array.from(promptStates.values());
|
||||
if (showPendingSummary) tasksArray = tasksArray.filter(t => t.status !== "pending");
|
||||
if (State.currentSearchQuery) tasksArray = tasksArray.filter(t => matchesFilter(t, State.currentSearchQuery));
|
||||
@@ -257,48 +781,113 @@ export function renderDOM() {
|
||||
if (!cardObj) {
|
||||
const card = document.createElement("div");
|
||||
const timerEl = document.createElement("div"); timerEl.className = "comfy-sidebar-card-timer";
|
||||
const cancelX = document.createElement("span"); cancelX.className = "pi pi-times";
|
||||
Object.assign(cancelX.style, { position: "absolute", top: "4px", right: "4px", color: "#dc3545", cursor: "pointer", fontSize: "20px", display: "none", zIndex: "10", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", transition: "color 0.2s" });
|
||||
const cancelX = document.createElement("span");
|
||||
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
|
||||
Object.assign(cancelX.style, {
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
display: "none",
|
||||
zIndex: "10"
|
||||
});
|
||||
const sBadge = document.createElement("div");
|
||||
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none" });
|
||||
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none", zIndex: "10" });
|
||||
const grid = document.createElement("div"); grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
const p = document.createElement("div"); Object.assign(p.style, { fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none" });
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none",
|
||||
display: "-webkit-box", WebkitLineClamp: "15", WebkitBoxOrient: "vertical", overflow: "hidden", wordBreak: "break-word"
|
||||
});
|
||||
const pt = document.createElement("div"); Object.assign(pt.style, { width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px", overflow: "hidden", display: "none" });
|
||||
const pb = document.createElement("div"); Object.assign(pb.style, { width: `0%`, height: "100%", background: "#3b82f6", transition: "width 0.1s linear" });
|
||||
pt.appendChild(pb);
|
||||
const statusText = document.createElement("div"); Object.assign(statusText.style, { fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px", display: "none", fontWeight: "bold" });
|
||||
const hoverPanel = document.createElement("div"); Object.assign(hoverPanel.style, { position: "absolute", bottom: "4px", right: "4px", display: "none", gap: "12px", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", zIndex: "20" });
|
||||
|
||||
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; Object.assign(btnImg.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
|
||||
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; Object.assign(btnJson.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
|
||||
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; Object.assign(btnDel.style, { cursor: "pointer", fontSize: "20px", color: "#dc3545", transition: "all 0.1s ease-in-out" });
|
||||
// Absolute Vertical Actions Panel on the right of the card
|
||||
const hoverPanel = document.createElement("div");
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleRightBtn(btnImg);
|
||||
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleRightBtn(btnJson);
|
||||
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleRightBtn(btnDel);
|
||||
|
||||
// Bottom left Explorer Submenu Button
|
||||
const leftHoverBtn = document.createElement("span");
|
||||
leftHoverBtn.className = "pi pi-images comfy-sidebar-left-hover-btn";
|
||||
leftHoverBtn.title = "View all intermediate outputs";
|
||||
Object.assign(leftHoverBtn.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
width: "32px", height: "32px",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "14px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: "20"
|
||||
});
|
||||
leftHoverBtn.onmouseenter = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; leftHoverBtn.style.color = "#fff"; };
|
||||
leftHoverBtn.onmouseleave = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; leftHoverBtn.style.color = "#e2e8f0"; };
|
||||
|
||||
leftHoverBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
State.activeSubmenuPromptId = state.pid;
|
||||
renderDOM();
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnJson, btnDel);
|
||||
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel);
|
||||
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverBtn);
|
||||
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
|
||||
card.addEventListener("mouseenter", () => { if (state.status !== "active" && state.status !== "pending") cardObj.hoverPanel.style.display = "flex"; });
|
||||
card.addEventListener("mouseleave", () => cardObj.hoverPanel.style.display = "none");
|
||||
card.addEventListener("mouseenter", () => {
|
||||
if (state.status !== "active" && state.status !== "pending") {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
// Show explorer view button if this run yielded multiple node outputs
|
||||
const outputs = getRunOutputs(state.nodeOutputs);
|
||||
if (outputs.length > 1) {
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
} else {
|
||||
cardObj.leftHoverBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverBtn.style.display = "none";
|
||||
});
|
||||
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
|
||||
if (state.images && state.images[0]) {
|
||||
State.currentDraggedImgData = { ...state.images[0], workflow: state.workflow };
|
||||
const img = state.images[0];
|
||||
const fileUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
|
||||
try { e.dataTransfer.setData("DownloadURL", `image/png:${img.filename || "output.png"}:${fileUrl}`); e.dataTransfer.setData("text/uri-list", fileUrl); e.dataTransfer.setData("text/plain", fileUrl); e.dataTransfer.items.add(new File([""], img.filename || "output.png", { type: "image/png" })); } catch (err) {}
|
||||
} else if (state.workflow) {
|
||||
State.currentDraggedImgData = { workflow: state.workflow };
|
||||
// Custom dragstart is only used for JSON-only workflows to supply a local DownloadURL.
|
||||
// For image cards, dragging is handled natively by the browser on the child <img> tag.
|
||||
if (state.workflow && (!state.images || state.images.length === 0)) {
|
||||
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
|
||||
const jsonStr = JSON.stringify(state.workflow, null, 2);
|
||||
try { e.dataTransfer.setData("DownloadURL", `application/json:workflow_${state.pid}.json:data:application/json;base64,` + btoa(unescape(encodeURIComponent(jsonStr)))); e.dataTransfer.setData("text/plain", jsonStr); e.dataTransfer.setData("application/json", jsonStr); } catch (err) {}
|
||||
try {
|
||||
e.dataTransfer.setData("DownloadURL", `application/json:workflow_${state.pid}.json:data:application/json;base64,` + btoa(unescape(encodeURIComponent(jsonStr))));
|
||||
e.dataTransfer.setData("text/plain", jsonStr);
|
||||
e.dataTransfer.setData("application/json", jsonStr);
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
cardElements.set(state.pid, cardObj);
|
||||
}
|
||||
|
||||
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
|
||||
cardObj.element.setAttribute("draggable", "true");
|
||||
|
||||
// Native drag isolation: completely remove draggable flags on image containers.
|
||||
// This exposes the child <img> tag directly to Chrome's native filesystem drag APIs.
|
||||
if (state.images && state.images.length > 0) {
|
||||
cardObj.element.removeAttribute("draggable");
|
||||
} else {
|
||||
cardObj.element.setAttribute("draggable", "true");
|
||||
}
|
||||
|
||||
if (state.status === "active") {
|
||||
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
|
||||
@@ -315,28 +904,58 @@ export function renderDOM() {
|
||||
|
||||
if (state.status === "pending" && !showPendingSummary) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.onclick = async (ev) => { ev.stopPropagation(); await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); await syncQueueFn(); };
|
||||
} else cardObj.cancelBtn.style.display = "none";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
|
||||
await syncQueueFn();
|
||||
};
|
||||
} else if (state.status === "active") {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
await api.interrupt(); // Natively interrupts current execution
|
||||
await syncQueueFn();
|
||||
};
|
||||
} else {
|
||||
cardObj.cancelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
if (state.images && state.images.length > 0) {
|
||||
cardObj.btnImg.style.display = "inline";
|
||||
cardObj.btnImg.style.display = "inline-flex";
|
||||
cardObj.btnImg.onclick = (ev) => { ev.stopPropagation(); state.images.forEach(img => { const a = document.createElement("a"); a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; a.download = img.filename || "output"; a.click(); }); };
|
||||
} else cardObj.btnImg.style.display = "none";
|
||||
|
||||
if (state.workflow) {
|
||||
cardObj.btnJson.style.display = "inline";
|
||||
cardObj.btnJson.style.display = "inline-flex";
|
||||
cardObj.btnJson.onclick = (ev) => { ev.stopPropagation(); const blob = new Blob([JSON.stringify(state.workflow, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `workflow_${state.pid}.json`; a.click(); URL.revokeObjectURL(url); };
|
||||
} else cardObj.btnJson.style.display = "none";
|
||||
|
||||
let deleteTimeout = null, isDeletePending = false;
|
||||
const resetDeleteBtn = () => { isDeletePending = false; Object.assign(cardObj.btnDel.style, { color: "#dc3545", background: "none", padding: "0" }); cardObj.btnDel.title = "Delete Element from History"; if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } };
|
||||
const resetDeleteBtn = () => {
|
||||
isDeletePending = false;
|
||||
Object.assign(cardObj.btnDel.style, {
|
||||
color: "#e2e8f0",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.75)"
|
||||
});
|
||||
cardObj.btnDel.title = "Delete Card";
|
||||
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
|
||||
};
|
||||
|
||||
cardObj.btnDel.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!isDeletePending) {
|
||||
isDeletePending = true; Object.assign(cardObj.btnDel.style, { color: "#fff", background: "#dc3545", borderRadius: "4px", padding: "2px" }); cardObj.btnDel.title = "Click again to confirm deletion";
|
||||
isDeletePending = true;
|
||||
Object.assign(cardObj.btnDel.style, {
|
||||
color: "#fff",
|
||||
backgroundColor: "#dc3545" // Changes to solid red on warning confirmation
|
||||
});
|
||||
cardObj.btnDel.title = "Click again to confirm deletion";
|
||||
deleteTimeout = setTimeout(resetDeleteBtn, 1000);
|
||||
} else {
|
||||
resetDeleteBtn(); promptStates.delete(state.pid); await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); renderDOM();
|
||||
resetDeleteBtn();
|
||||
promptStates.delete(state.pid);
|
||||
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
|
||||
renderDOM();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -346,46 +965,17 @@ export function renderDOM() {
|
||||
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null; cardObj.placeholder.style.display = "block";
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
if (cardObj.grid.children.length === state.images.length) {
|
||||
state.images.forEach((img, idx) => {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
|
||||
const thumb = cardObj.grid.children[idx];
|
||||
if (thumb.src !== src) {
|
||||
if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { const oldBlob = thumb._lastBlob; thumb.src = src; thumb._lastBlob = src; if (oldBlob && oldBlob !== src) try { URL.revokeObjectURL(oldBlob); } catch(e){} }; tempImg.src = src; }
|
||||
else thumb.src = src;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null;
|
||||
state.images.forEach(img => {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
|
||||
const isVideo = isVideoFormat(src);
|
||||
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
Object.assign(thumb.style, { width: "100%", borderRadius: "2px", display: "block", cursor: "zoom-in" });
|
||||
if (isVideo) { thumb.autoplay = true; thumb.loop = true; thumb.muted = true; thumb.playsInline = true; }
|
||||
if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
|
||||
thumb.setAttribute("draggable", "false");
|
||||
|
||||
if (!isVideo) {
|
||||
thumb.onload = () => { if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`; };
|
||||
if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { thumb.src = src; thumb._lastBlob = src; }; tempImg.src = src; } else thumb.src = src;
|
||||
} else {
|
||||
thumb.onloadedmetadata = () => { if (keepAspect && thumb.videoWidth && thumb.videoHeight) thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`; }; thumb.src = src;
|
||||
}
|
||||
thumb.onclick = (ev) => { ev.stopPropagation(); showFullscreenPreview(state.images.map(i => i.url ? i.url : `/view?filename=${encodeURIComponent(i.filename)}&type=${i.type}&subfolder=${encodeURIComponent(i.subfolder)}`)); };
|
||||
if (!cardObj.firstImgElement) cardObj.firstImgElement = thumb;
|
||||
cardObj.grid.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
|
||||
if (state.images.length === 0) {
|
||||
// Correctly display accumulated texts (like workflow prompts) while running as requested
|
||||
if (state.texts && state.texts.length > 0) {
|
||||
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left", fontSize: "10px", opacity: "0.8" });
|
||||
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left" });
|
||||
} else {
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center", fontSize: "11px", opacity: "0.5" });
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -45,6 +45,19 @@ export function findTextsInOutputs(outputs) {
|
||||
return list;
|
||||
}
|
||||
|
||||
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
|
||||
export function getRunOutputs(nodeOutputs) {
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] });
|
||||
if (imgs.length > 0) {
|
||||
list.push({ nodeId, images: imgs });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function matchesFilter(state, query) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user