From 8343f216fc4e989591d94de24b0948e8c22fae5b Mon Sep 17 00:00:00 2001 From: m0rtus59 Date: Thu, 10 Sep 2026 21:57:14 +0500 Subject: [PATCH] Refactor. Improve light theme compatibility. Add offline 3D viewer & CSP support. Improve file deletion logic --- .gitignore | 3 +- __init__.py | 59 +- js/comfy/adapter.js | 202 ++++ js/{ => comfy}/layout.js | 118 +- js/comfy/nodes.js | 177 +++ js/core/constants.js | 32 + js/core/persistence.js | 87 ++ js/core/promptState.js | 75 ++ js/core/store.js | 189 ++++ js/queue.js | 428 ------- js/queue/executionTracker.js | 152 +++ js/queue/queueNormalizer.js | 75 ++ js/queue/queueService.js | 306 +++++ js/sidebar.js | 580 ++++------ js/state.js | 121 -- js/ui.js | 1967 --------------------------------- js/ui/audioController.js | 34 + js/ui/cardRenderer.js | 519 +++++++++ js/ui/mediaActions.js | 119 ++ js/ui/mediaPreview.js | 623 +++++++++++ js/ui/sidebarView.js | 912 +++++++++++++++ js/{ => utils}/comparison.js | 24 +- js/{ => utils}/dragdrop.js | 4 +- js/{ => utils}/overlay.js | 29 +- js/{ => utils}/styles.js | 0 js/{ => utils}/text_reader.js | 23 +- js/{ => utils}/utils.js | 254 +++-- js/{ => utils}/viewer3d.js | 94 +- pyproject.toml | 2 +- 29 files changed, 4086 insertions(+), 3122 deletions(-) create mode 100644 js/comfy/adapter.js rename js/{ => comfy}/layout.js (77%) create mode 100644 js/comfy/nodes.js create mode 100644 js/core/constants.js create mode 100644 js/core/persistence.js create mode 100644 js/core/promptState.js create mode 100644 js/core/store.js delete mode 100644 js/queue.js create mode 100644 js/queue/executionTracker.js create mode 100644 js/queue/queueNormalizer.js create mode 100644 js/queue/queueService.js delete mode 100644 js/state.js delete mode 100644 js/ui.js create mode 100644 js/ui/audioController.js create mode 100644 js/ui/cardRenderer.js create mode 100644 js/ui/mediaActions.js create mode 100644 js/ui/mediaPreview.js create mode 100644 js/ui/sidebarView.js rename js/{ => utils}/comparison.js (96%) rename js/{ => utils}/dragdrop.js (97%) rename js/{ => utils}/overlay.js (73%) rename js/{ => utils}/styles.js (100%) rename js/{ => utils}/text_reader.js (95%) rename js/{ => utils}/utils.js (59%) rename js/{ => utils}/viewer3d.js (85%) diff --git a/.gitignore b/.gitignore index 7bfcd7a..14cc7bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ -*.pyc \ No newline at end of file +*.pyc +js/vendor/ \ No newline at end of file diff --git a/__init__.py b/__init__.py index fd4f1cb..56eab1e 100644 --- a/__init__.py +++ b/__init__.py @@ -180,4 +180,61 @@ async def promote_output_handler(request): shutil.copy2(target_file, dest_path) return web.json_response({"name": filename}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) \ No newline at end of file + return web.json_response({"error": str(e)}, status=500) + + +# ============================================================================ +# Local Three.js Proxy & Offline Cache (Bypasses CSP & --disable-api-nodes) +# ============================================================================ +SIDEBAR_CACHE_DIR = os.path.join(os.path.dirname(__file__), "js", "vendor", "cache") + +@PromptServer.instance.routes.get("/comfy-sidebar/three-proxy/{path:.*}") +async def three_proxy_handler(request): + raw_path = request.match_info.get("path", "").lstrip("/") + if not raw_path: + return web.Response(status=404) + + query = request.query_string + full_req = f"{raw_path}?{query}" if query else raw_path + + os.makedirs(SIDEBAR_CACHE_DIR, exist_ok=True) + import re + safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', full_req) + if not any(safe_name.endswith(ext) for ext in [".js", ".mjs", ".wasm", ".bin", ".json"]): + safe_name += ".js" + cache_file = os.path.join(SIDEBAR_CACHE_DIR, safe_name) + + # 1. Serve immediately from disk cache if present + if os.path.isfile(cache_file) and os.path.getsize(cache_file) > 0: + content_type = "application/wasm" if raw_path.endswith(".wasm") else "application/javascript" + return web.FileResponse(cache_file, headers={"Content-Type": content_type}) + + # 2. Otherwise download via Python (not blocked by browser CSP) + url = f"https://esm.sh/{full_req}" + try: + import urllib.request + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}) + with urllib.request.urlopen(req, timeout=20) as resp: + raw_data = resp.read() + + if raw_path.endswith((".wasm", ".bin", ".png", ".jpg")): + with open(cache_file, "wb") as f: + f.write(raw_data) + return web.Response(body=raw_data, content_type="application/octet-stream") + + content = raw_data.decode("utf-8", errors="replace") + # Rewrite absolute esm.sh paths to route through our local 'self' proxy + content = content.replace("https://esm.sh/", "/comfy-sidebar/three-proxy/") + # Rewrite root-relative module paths (e.g. from "/v135/...") + content = re.sub( + r'((?:from|import)\s*["\'])/(v\d+|[a-zA-Z0-9@_.~-]+/)', + r'\1/comfy-sidebar/three-proxy/\2', + content + ) + + with open(cache_file, "w", encoding="utf-8") as f: + f.write(content) + + return web.Response(text=content, content_type="application/javascript") + except Exception as e: + return web.Response(text=f"/* Proxy error fetching {url}: {e} */", status=502, content_type="application/javascript") \ No newline at end of file diff --git a/js/comfy/adapter.js b/js/comfy/adapter.js new file mode 100644 index 0000000..517031a --- /dev/null +++ b/js/comfy/adapter.js @@ -0,0 +1,202 @@ +import { app } from "/scripts/app.js"; + +export function findOriginalPropertiesButton() { + const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]'); + if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) { + return byTestId; + } + + const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]'); + for (const icon of icons) { + const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button'); + if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) { + return btn; + } + } + + const buttons = document.querySelectorAll('button, [role="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("workflow overview") || title.includes("properties") || title.includes("toggle panel")) { + return btn; + } + } + return null; +} + +export function findTopbarContainer() { + const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]'); + if (container) return container; + + const orig = findOriginalPropertiesButton(); + if (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("manager")) { + if (btn.parentNode) return btn.parentNode; + } + } + 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; + } + + const origBtn = findOriginalPropertiesButton(); + if (origBtn) { + if (origBtn.classList.contains("p-button-active") || + origBtn.classList.contains("active") || + origBtn.getAttribute("aria-expanded") === "true" || + origBtn.getAttribute("aria-pressed") === "true") { + return true; + } + } + return false; +} + +export 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; +} + +export function findNativeExtensionsPanel() { + return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)')) + .find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions')); +} + +export function findGraphButton() { + const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]'); + for (const el of elements) { + if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue; + return el.closest('.group, .p-1') || el; + } + + const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background'); + for (const el of fallbacks) { + if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue; + const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase(); + if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) { + return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el; + } + } + return null; +} + +export function findOurSidebarButton() { + const icon = document.querySelector('.pi-images'); + return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null; +} + +export function findStandardQueueButton() { + for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) { + const icon = document.querySelector(iconSelector); + if (icon) { + const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]'); + if (btn && !btn.querySelector('.pi-images')) return btn; + } + } + const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]'); + for (const btn of buttons) { + const title = btn.title || btn.getAttribute('aria-label') || ''; + if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) && + !btn.querySelector('.pi-images') && + !btn.id?.includes('classic-comfy-sidebar')) { + return btn; + } + } + return null; +} + +export function updateSidebarBadge(count) { + const icons = document.querySelectorAll('.pi-images'); + icons.forEach(icon => { + const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]'); + if (btn) { + let badge = btn.querySelector('.comfy-sidebar-badge'); + if (count > 0) { + if (!badge) { + btn.style.position = 'relative'; + badge = document.createElement('div'); + badge.className = 'comfy-sidebar-badge'; + Object.assign(badge.style, { + position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff', + borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex', + alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none' + }); + btn.appendChild(badge); + } + badge.textContent = count > 99 ? '99+' : count; + } else if (badge) { + badge.remove(); + } + } + }); +} + +export function applySidebarOverride() { + const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false; + const stdBtn = findStandardQueueButton(); + const ourBtn = findOurSidebarButton(); + + if (stdBtn) { + if (!stdBtn._originalDisplay) { + stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block"; + } + if (overrideStock) { + stdBtn.style.setProperty("display", "none", "important"); + if (!stdBtn._overrideClickListener) { + stdBtn._overrideClickListener = (e) => { + e.preventDefault(); + e.stopPropagation(); + const b = findOurSidebarButton(); + if (b) b.click(); + }; + stdBtn.addEventListener('click', stdBtn._overrideClickListener, true); + } + if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) { + stdBtn.parentNode.insertBefore(ourBtn, stdBtn); + } + } else { + stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay); + if (stdBtn._overrideClickListener) { + stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true); + stdBtn._overrideClickListener = null; + } + if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) { + stdBtn.parentNode.appendChild(ourBtn); + } + } + } +} + +export function centerAndSelectCanvasNode(nodeId) { + if (!app.graph || !app.canvas) return false; + const id = typeof nodeId === "string" ? Number(nodeId) : nodeId; + const node = app.graph.getNodeById ? (app.graph.getNodeById(id) || app.graph.getNodeById(String(nodeId))) : null; + if (node) { + if (app.canvas.centerOnNode) app.canvas.centerOnNode(node); + if (app.canvas.selectNode) app.canvas.selectNode(node); + return true; + } + return false; +} diff --git a/js/layout.js b/js/comfy/layout.js similarity index 77% rename from js/layout.js rename to js/comfy/layout.js index 51b6b13..281d933 100644 --- a/js/layout.js +++ b/js/comfy/layout.js @@ -1,4 +1,12 @@ import { app } from "/scripts/app.js"; +import { + findOriginalPropertiesButton, + findTopbarContainer, + isPropertiesPanelOpen, + findActiveQueueIndicator, + findNativeExtensionsPanel, + findGraphButton +} from "./adapter.js"; const STYLE_ID = "comfy-sidebar-classic-layout-override"; @@ -151,90 +159,6 @@ let savedButtonData = null; let domObserver = null; let syncScheduled = false; -function findOriginalButton() { - const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]'); - if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) { - return byTestId; - } - - const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]'); - for (const icon of icons) { - const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button'); - if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) { - return btn; - } - } - - const buttons = document.querySelectorAll('button, [role="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("workflow overview") || title.includes("properties") || title.includes("toggle panel")) { - return btn; - } - } - return null; -} - -function findTopbarContainer() { - const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]'); - if (container) return container; - - const orig = findOriginalButton(); - if (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("manager")) { - if (btn.parentNode) return btn.parentNode; - } - } - return null; -} - -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; - } - - const origBtn = findOriginalButton(); - if (origBtn) { - if (origBtn.classList.contains("p-button-active") || - origBtn.classList.contains("active") || - origBtn.getAttribute("aria-expanded") === "true" || - origBtn.getAttribute("aria-pressed") === "true") { - return true; - } - } - return false; -} - -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 findNativeExtensionsPanel() { - return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)')) - .find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions')); -} - function updateSidebarTabsVisibility() { const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]'); if (!sidebar) return; @@ -273,24 +197,6 @@ function updateSidebarTabsVisibility() { } } -function findGraphButton() { - const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]'); - for (const el of elements) { - if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue; - return el.closest('.group, .p-1') || el; - } - - const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background'); - for (const el of fallbacks) { - if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue; - const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase(); - if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) { - return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el; - } - } - return null; -} - function syncErrorBadge(originalBtn, customBtn) { if (!customBtn) return; @@ -352,7 +258,7 @@ export function syncClassicLayout() { } if (!isClassicLayoutEnabled) { - const originalBtn = findOriginalButton(); + const originalBtn = findOriginalPropertiesButton(); if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) { originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn"); } @@ -367,7 +273,7 @@ export function syncClassicLayout() { return; } - const originalBtn = findOriginalButton(); + const originalBtn = findOriginalPropertiesButton(); const container = findTopbarContainer(); const openState = isPropertiesPanelOpen(); @@ -404,7 +310,7 @@ export function syncClassicLayout() { e.preventDefault(); e.stopPropagation(); - const nativeBtn = findOriginalButton(); + const nativeBtn = findOriginalPropertiesButton(); if (nativeBtn) { nativeBtn.click(); requestAnimationFrame(() => syncClassicLayout()); @@ -548,4 +454,4 @@ export function destroyLayoutFix() { if (styleEl) styleEl.remove(); const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles"); if (fixStyles) fixStyles.remove(); -} \ No newline at end of file +} diff --git a/js/comfy/nodes.js b/js/comfy/nodes.js new file mode 100644 index 0000000..1de6f40 --- /dev/null +++ b/js/comfy/nodes.js @@ -0,0 +1,177 @@ +import { app } from "/scripts/app.js"; +import { applySidebarOverride } from "./adapter.js"; + +let nodeDOMObserver = null; + +function findHeaderByText(parent, text) { + if (!text) return null; + const cleanText = text.trim(); + + const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']"); + for (const el of elements) { + if (el.textContent.trim() === cleanText) { + if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) { + return el; + } + } + } + return null; +} + +export function syncNodeVueBadge(node, isIgnored) { + const nodeEl = document.querySelector( + `.comfy-node[data-node-id="${node.id}"], ` + + `[data-node-id="${node.id}"], ` + + `[data-id="${node.id}"]` + ); + if (!nodeEl) return; + + const titleTextNode = findHeaderByText(nodeEl, node.title || node.type); + if (!titleTextNode) { + const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge"); + if (badge && !isIgnored) badge.remove(); + return; + } + + const headerEl = titleTextNode.parentNode; + let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge"); + + if (isIgnored) { + if (!badge) { + badge = document.createElement("div"); + badge.className = "comfy-sidebar-ignore-badge"; + badge.innerHTML = ` + + + + + + + `; + Object.assign(badge.style, { + marginLeft: "auto", + marginRight: "6px", + float: "right", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + color: "inherit", + pointerEvents: "none" + }); + headerEl.appendChild(badge); + } + } else { + if (badge) badge.remove(); + } +} + +export function syncAllNodeBadges() { + applySidebarOverride(); + if (!app.graph || !app.graph._nodes) return; + + app.graph._nodes.forEach(node => { + if (!node.properties) node.properties = {}; + const isIgnored = !!node.properties.ignoreInQueue; + + if (isIgnored) { + if (node.boxcolor !== "#ff3333") { + node._oldBoxcolor = node.boxcolor || ""; + node.boxcolor = "#ff3333"; + if (app.graph) app.graph.setDirtyCanvas(true, true); + } + } else { + if (node.boxcolor === "#ff3333") { + node.boxcolor = node._oldBoxcolor || ""; + delete node._oldBoxcolor; + if (app.graph) app.graph.setDirtyCanvas(true, true); + } + } + + syncNodeVueBadge(node, isIgnored); + }); +} + +export function setupVueNodeObserver() { + if (nodeDOMObserver) nodeDOMObserver.disconnect(); + + let timeoutId = null; + nodeDOMObserver = new MutationObserver((mutations) => { + let hasNodeMutation = false; + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType === 1) { + if (node.hasAttribute?.("data-node-id") || + node.classList?.contains("comfy-node") || + node.querySelector?.('[data-node-id], .comfy-node')) { + hasNodeMutation = true; + break; + } + } + } + if (hasNodeMutation) break; + } + + if (hasNodeMutation) { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + syncAllNodeBadges(); + }, 50); + } + }); + + nodeDOMObserver.observe(document.body, { + childList: true, + subtree: true + }); + + return () => { + if (nodeDOMObserver) { + nodeDOMObserver.disconnect(); + nodeDOMObserver = null; + } + }; +} + +export function toggleIgnoreActiveNode(onModified) { + const canvas = app.canvas; + if (!canvas) return; + + let nodes = []; + if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) { + nodes = Object.values(canvas.selected_nodes); + } else if (canvas.current_active_node) { + nodes = [canvas.current_active_node]; + } + + if (nodes.length === 0) return; + + nodes.forEach(node => { + if (!node.properties) node.properties = {}; + node.properties.ignoreInQueue = !node.properties.ignoreInQueue; + const isIgnored = !!node.properties.ignoreInQueue; + + if (isIgnored) { + if (node.boxcolor !== "#ff3333") { + node._oldBoxcolor = node.boxcolor || ""; + node.boxcolor = "#ff3333"; + } + } else { + if (node.boxcolor === "#ff3333") { + node.boxcolor = node._oldBoxcolor || ""; + delete node._oldBoxcolor; + } + } + + syncNodeVueBadge(node, isIgnored); + }); + + if (app.graph) { + if (app.graph.change) app.graph.change(); + app.graph.setDirtyCanvas(true, true); + } + if (app.canvas) app.canvas.setDirty(true, true); + + if (typeof onModified === "function") { + onModified(); + } +} diff --git a/js/core/constants.js b/js/core/constants.js new file mode 100644 index 0000000..22cb1d5 --- /dev/null +++ b/js/core/constants.js @@ -0,0 +1,32 @@ +export const PromptStatus = Object.freeze({ + PENDING: "pending", + ACTIVE: "active", + COMPLETED: "completed", + CANCELLED: "cancelled", + ERROR: "error" +}); + +export const StoreEvents = Object.freeze({ + PROMPT_ADDED: "prompt:added", + PROMPT_UPDATED: "prompt:updated", + PROMPT_DELETED: "prompt:deleted", + PROMPTS_CLEARED: "prompts:cleared", + ACTIVE_PROMPT_CHANGED: "activePrompt:changed", + SEARCH_CHANGED: "search:changed", + SUBMENU_CHANGED: "submenu:changed", + QUEUE_SYNCED: "queue:synced" +}); + +export const SettingIds = Object.freeze({ + GRID_COLUMNS_THRESHOLD: "Comfy Sidebar.Grid Columns Threshold", + SHOW_PENDING_COUNT_ONLY: "Comfy Sidebar.Show Pending Count Only", + SHOW_WORKING_NODE_NAME: "Comfy Sidebar.Show Working Node Name", + AUTO_CLEAR_INTERRUPTED: "Comfy Sidebar.Auto Clear Interrupted", + OVERRIDE_STOCK_HISTORY: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab", + HIDE_GRAPH_BUTTON: "Comfy Sidebar.Hide Junk.Graph Button", + CLASSIC_LAYOUT: "Comfy Sidebar.Comfy Layout", + MAX_HISTORY_ITEMS: "Comfy.Queue.MaxHistoryItems", + DOCK_QUEUE: "Comfy.Queue.QPOV2", + SHOW_PROGRESS_BAR: "Comfy.Queue.ShowRunProgressBar", + TAB_POSITION: "Comfy.Workflow.WorkflowTabsPosition" +}); diff --git a/js/core/persistence.js b/js/core/persistence.js new file mode 100644 index 0000000..1398c13 --- /dev/null +++ b/js/core/persistence.js @@ -0,0 +1,87 @@ +import { PromptStatus } from "./constants.js"; + +const STORAGE_KEY = "comfy_sidebar_prompt_states"; + +export const PersistenceAdapter = { + load() { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return []; + const list = JSON.parse(data); + return Array.isArray(list) ? list : []; + } catch (e) { + console.warn("Comfy Sidebar: Failed to load states from localStorage", e); + return []; + } + }, + + save(promptsMap) { + try { + const serializable = []; + + for (const [pid, state] of promptsMap.entries()) { + const cleanedImages = (state.images || []).map(img => { + if (img && img.url && img.url.startsWith("blob:")) return null; + return img; + }).filter(Boolean); + + const includeWorkflow = cleanedImages.length === 0 || state.status !== PromptStatus.COMPLETED; + + serializable.push({ + pid: String(state.pid), + status: state.status, + images: cleanedImages, + texts: state.texts || [], + nodeOutputs: state.nodeOutputs || {}, + nodeTitles: state.nodeTitles || {}, // Always preserved across restarts + workflow: includeWorkflow ? state.workflow : null, + progress: state.progress || 0, + queueNumber: state.queueNumber, + progressText: state.progressText || "", + timestamp: state.timestamp, + activeNodeName: state.activeNodeName || "", + rendered: !!state.rendered, + startTime: state.startTime, + endTime: state.endTime, + duration: state.duration + }); + } + + if (serializable.length === 0) { + localStorage.removeItem(STORAGE_KEY); + return; + } + + serializable.sort((a, b) => a.timestamp - b.timestamp); + + while (serializable.length > 0) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable)); + break; + } catch (e) { + if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) { + serializable.shift(); + } else { + console.warn("Comfy Sidebar: LocalStorage error", e); + break; + } + } + } + } catch (e) { + console.warn("Comfy Sidebar: Failed to serialize states", e); + } + }, + + getPruneCandidates(promptsMap, maxItems = 64) { + const finished = Array.from(promptsMap.entries()) + .filter(([_, state]) => state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE); + + finished.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0)); + + if (finished.length > maxItems) { + const deleteCount = finished.length - maxItems; + return finished.slice(0, deleteCount).map(([pid]) => pid); + } + return []; + } +}; \ No newline at end of file diff --git a/js/core/promptState.js b/js/core/promptState.js new file mode 100644 index 0000000..4f2e540 --- /dev/null +++ b/js/core/promptState.js @@ -0,0 +1,75 @@ +import { PromptStatus } from "./constants.js"; + +export function createPromptState(pid, initial = {}) { + return { + pid: String(pid), + status: initial.status || PromptStatus.PENDING, + images: Array.isArray(initial.images) ? [...initial.images] : [], + texts: Array.isArray(initial.texts) ? [...initial.texts] : [], + nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {}, + nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {}, + progress: typeof initial.progress === "number" ? initial.progress : 0, + progressText: initial.progressText || "", + queueNumber: initial.queueNumber || null, + timestamp: typeof initial.timestamp === "number" ? initial.timestamp : Date.now(), + workflow: initial.workflow || null, + activeNodeName: initial.activeNodeName || "", + startTime: initial.startTime || null, + endTime: initial.endTime || null, + duration: typeof initial.duration === "number" ? initial.duration : null, + rendered: !!initial.rendered, + _previewBlobUrl: initial._previewBlobUrl || null, + _oldPreviewBlobUrl: initial._oldPreviewBlobUrl || null + }; +} + +export function revokePromptBlobs(state) { + if (!state) return; + if (state._previewBlobUrl) { + try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {} + state._previewBlobUrl = null; + } + if (state._oldPreviewBlobUrl) { + try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {} + state._oldPreviewBlobUrl = null; + } +} + +export function activatePrompt(state, patch = {}) { + state.status = PromptStatus.ACTIVE; + state.progress = 0; + state.progressText = "Processing..."; + state.rendered = false; + state.startTime = state.startTime || Date.now(); + state.duration = null; + if (patch.workflow) state.workflow = patch.workflow; + if (patch.activeNodeName !== undefined) state.activeNodeName = patch.activeNodeName; + if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles); + if (typeof patch.timestamp === "number") state.timestamp = patch.timestamp; + return state; +} + +export function updatePromptProgress(state, progress, activeNodeName = null) { + state.progress = Math.max(0, Math.min(100, Math.round(progress))); + if (activeNodeName !== null) { + state.activeNodeName = activeNodeName; + } + return state; +} + +export function finalizePrompt(state, targetStatus, patch = {}) { + revokePromptBlobs(state); + state.status = targetStatus; + state.progressText = ""; + state.rendered = false; + state.endTime = patch.endTime || Date.now(); + if (state.startTime) { + state.duration = (state.endTime - state.startTime) / 1000; + } + if (patch.workflow) state.workflow = patch.workflow; + if (patch.nodeOutputs) state.nodeOutputs = patch.nodeOutputs; + if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles); + if (patch.images && patch.images.length > 0) state.images = patch.images; + if (patch.texts && patch.texts.length > 0) state.texts = patch.texts; + return state; +} \ No newline at end of file diff --git a/js/core/store.js b/js/core/store.js new file mode 100644 index 0000000..9370a5e --- /dev/null +++ b/js/core/store.js @@ -0,0 +1,189 @@ +import { PromptStatus, StoreEvents } from "./constants.js"; +import { createPromptState, revokePromptBlobs } from "./promptState.js"; +import { PersistenceAdapter } from "./persistence.js"; + +class Store { + constructor() { + this.prompts = new Map(); + this.ui = { + searchQuery: "", + currentlyActivePromptId: null, + activeSubmenuPromptId: null, + activeSubmenuBatchImages: null, + mainQueueScrollTop: null, + sequenceNumber: 0, + sidebarContainer: null, + cardStack: null + }; + this.listeners = new Map(); + this._saveTimer = null; + this.init(); + } + + init() { + const saved = PersistenceAdapter.load(); + for (const item of saved) { + const state = createPromptState(item.pid, item); + this.prompts.set(String(state.pid), state); + if (state.timestamp > this.ui.sequenceNumber) { + this.ui.sequenceNumber = state.timestamp; + } + } + } + + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event).add(callback); + return () => this.off(event, callback); + } + + off(event, callback) { + if (this.listeners.has(event)) { + this.listeners.get(event).delete(callback); + } + } + + emit(event, payload) { + if (this.listeners.has(event)) { + for (const callback of this.listeners.get(event)) { + try { + callback(payload); + } catch (err) { + console.error(`Store error during event '${event}':`, err); + } + } + } + } + + nextSequence() { + return ++this.ui.sequenceNumber; + } + + scheduleSave(delay = 250) { + if (this._saveTimer) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this._saveTimer = null; + PersistenceAdapter.save(this.prompts); + }, delay); + } + + getPrompt(pid) { + return this.prompts.get(String(pid)) || null; + } + + hasPrompt(pid) { + return this.prompts.has(String(pid)); + } + + getAllPrompts() { + return Array.from(this.prompts.values()); + } + + setPrompt(pid, state) { + const key = String(pid); + const isNew = !this.prompts.has(key); + this.prompts.set(key, state); + this.scheduleSave(); + this.emit(isNew ? StoreEvents.PROMPT_ADDED : StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state }); + return state; + } + + updatePrompt(pid, patch) { + const key = String(pid); + const state = this.prompts.get(key); + if (!state) return null; + Object.assign(state, patch); + this.scheduleSave(); + this.emit(StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state, patch }); + return state; + } + + deletePrompt(pid) { + const key = String(pid); + const state = this.prompts.get(key); + if (state) { + revokePromptBlobs(state); + this.prompts.delete(key); + if (this.ui.currentlyActivePromptId === key) { + this.ui.currentlyActivePromptId = null; + } + if (this.ui.activeSubmenuPromptId === key) { + this.ui.activeSubmenuPromptId = null; + } + this.scheduleSave(); + this.emit(StoreEvents.PROMPT_DELETED, { pid: key }); + return true; + } + return false; + } + + pruneHistory(maxItems = 64) { + const candidates = PersistenceAdapter.getPruneCandidates(this.prompts, maxItems); + for (const pid of candidates) { + this.deletePrompt(pid); + } + } + + clearFinished() { + const toDelete = []; + for (const [pid, state] of this.prompts.entries()) { + if (state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE) { + toDelete.push(pid); + } + } + for (const pid of toDelete) { + this.deletePrompt(pid); + } + this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length }); + return toDelete; + } + + clearCancelledOrFailed() { + const toDelete = []; + for (const [pid, state] of this.prompts.entries()) { + if (state.status === PromptStatus.CANCELLED || state.status === PromptStatus.ERROR) { + toDelete.push(pid); + } + } + for (const pid of toDelete) { + this.deletePrompt(pid); + } + this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length }); + return toDelete; + } + + setSearchQuery(query) { + this.ui.searchQuery = (query || "").trim(); + this.emit(StoreEvents.SEARCH_CHANGED, { query: this.ui.searchQuery }); + } + + setActivePromptId(pid) { + this.ui.currentlyActivePromptId = pid ? String(pid) : null; + this.emit(StoreEvents.ACTIVE_PROMPT_CHANGED, { pid: this.ui.currentlyActivePromptId }); + } + + openBatchSubmenu(batchInfo) { + this.ui.activeSubmenuBatchImages = batchInfo; + this.ui.activeSubmenuPromptId = null; + this.emit(StoreEvents.SUBMENU_CHANGED, { type: "batch", data: batchInfo }); + } + + openOutputsSubmenu(pid) { + this.ui.activeSubmenuPromptId = String(pid); + this.ui.activeSubmenuBatchImages = null; + this.emit(StoreEvents.SUBMENU_CHANGED, { type: "outputs", pid: this.ui.activeSubmenuPromptId }); + } + + closeSubmenu() { + const hadSubmenu = this.ui.activeSubmenuPromptId !== null || this.ui.activeSubmenuBatchImages !== null; + this.ui.activeSubmenuPromptId = null; + this.ui.activeSubmenuBatchImages = null; + if (hadSubmenu) { + this.emit(StoreEvents.SUBMENU_CHANGED, { type: null }); + } + } +} + +export const store = new Store(); diff --git a/js/queue.js b/js/queue.js deleted file mode 100644 index 56ba143..0000000 --- a/js/queue.js +++ /dev/null @@ -1,428 +0,0 @@ -import { app } from "/scripts/app.js"; -import { api } from "/scripts/api.js"; -import { State, promptStates, pruneHistory, cardElements, scheduleStateSave } from "./state.js"; -import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js"; - -export let renderDOMFn = () => {}; -export let updateSidebarBadgeFn = () => {}; -export function setUIDependencies(renderFn, badgeFn) { - renderDOMFn = renderFn; - updateSidebarBadgeFn = badgeFn; -} - -export async function syncQueue() { - try { - const q = await api.getQueue(); - - const runningList = q.Running || q.queue_running || []; - const pendingList = q.Pending || q.queue_pending || []; - const pendingIds = new Set(); - const runningIds = new Set(); - - runningList.forEach(p => { - let pid = null; - let pWorkflow = null; - let pOutputs = {}; - - if (Array.isArray(p)) { - pid = p[1] ? String(p[1]) : null; - const extraData = p[3] || {}; - pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow); - pOutputs = p[4] || {}; - } else if (p && typeof p === "object") { - pid = (p.prompt_id || p.id || p.uuid) ? String(p.prompt_id || p.id || p.uuid) : null; - const extraData = p.extra_data || {}; - pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow); - pOutputs = p.outputs || {}; - } - - if (pid) { - runningIds.add(pid); - State.currentlyActivePromptId = pid; - - // Restore active card if refreshed mid-generation - if (!promptStates.has(pid)) { - State.sequenceNumber++; - const activeWorkflow = pWorkflow || app.graph?.serialize?.() || null; - const images = getPrimaryOutputImages(pOutputs, activeWorkflow); - const texts = findTextsInOutputs(pOutputs, activeWorkflow); - - promptStates.set(pid, { - pid: pid, - status: "active", - images: images, - texts: texts, - nodeOutputs: pOutputs, - progress: 0, - progressText: "Processing...", - timestamp: State.sequenceNumber, - workflow: activeWorkflow, - startTime: Date.now(), - duration: null - }); - } else { - const st = promptStates.get(pid); - if (st.status !== "active") { - st.status = "active"; - st.progressText = "Processing..."; - st.rendered = false; - if (!st.startTime) st.startTime = Date.now(); - } - if (!st.workflow && pWorkflow) { - st.workflow = pWorkflow; - } - } - } - }); - - const normalizedPending = pendingList.map((p, idx) => { - let pid = null, seq = idx; - if (Array.isArray(p)) { - seq = typeof p[0] === 'number' ? p[0] : idx; - pid = p[1]; - } else if (p && typeof p === "object") { - pid = p.prompt_id || p.id || p.uuid; - seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx); - } - return { pid: pid ? String(pid) : null, seq, original: p }; - }); - - normalizedPending.sort((a, b) => a.seq - b.seq); - normalizedPending.forEach((item, index) => { - const pid = item.pid; - const number = index + 1; - - if (pid) { - pendingIds.add(pid); - if (!promptStates.has(pid)) { - State.sequenceNumber++; - promptStates.set(pid, { - pid: pid, status: "pending", images: [], progress: 0, queueNumber: number, - progressText: `Pending... (#${number})`, timestamp: State.sequenceNumber, - workflow: app.graph.serialize() - }); - } else { - const st = promptStates.get(pid); - if (st.status === "pending") { - st.queueNumber = number; - st.progressText = `Pending... (#${number})`; - } - } - } - }); - - for (const [pid, state] of promptStates.entries()) { - if (state.status === "pending" && !pendingIds.has(pid)) { - promptStates.delete(pid); - } else if (state.status === "active" && !runningIds.has(pid)) { - concludeRun(pid, "cancelled"); - } - } - - updateSidebarBadgeFn(pendingIds.size + (runningIds.size > 0 ? 1 : 0)); - renderDOMFn(); - } catch (err) { - console.error("Comfy Sidebar: Failed to sync queue state", err); - } -} - -const concludeRun = async (pid, statusStr) => { - const key = String(pid); - if (!key || !promptStates.has(key)) return; - if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null; - - const st = promptStates.get(key); - - if (st._previewBlobUrl) { - try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){} - delete st._previewBlobUrl; - } - if (st._oldPreviewBlobUrl) { - try { URL.revokeObjectURL(st._oldPreviewBlobUrl); } catch(e){} - delete st._oldPreviewBlobUrl; - } - - st.status = statusStr; - st.progressText = ""; - st.rendered = false; - st.endTime = Date.now(); - if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000; - - for (let attempt = 0; attempt < 3; attempt++) { - try { - const res = await fetch(`/history/${key}`); - const hItem = await res.json(); - if (hItem && hItem[key]) { - const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null; - st.workflow = parseWorkflow(rawWf) || st.workflow; - st.nodeOutputs = hItem[key].outputs; - - const serverStatus = hItem[key].status?.status_str; - const isInterrupted = statusStr === "cancelled" || - serverStatus === "interrupted" || - (hItem[key].status?.messages && JSON.stringify(hItem[key].status.messages).toLowerCase().includes("interrupted")); - - if (serverStatus === "success" || serverStatus === "completed") { - st.status = "completed"; - } else if (isInterrupted) { - st.status = "cancelled"; - } else if (serverStatus === "error") { - st.status = "error"; - } - - const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow); - if (primaryImgs.length > 0) st.images = primaryImgs; - - const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow); - if (fetchedTexts.length > 0) st.texts = fetchedTexts; - break; - } - } catch (err) {} - await new Promise(r => setTimeout(r, 200)); - } - - pruneHistory(app); - scheduleStateSave(); - syncQueue(); -}; - -export function setupApiListeners() { - const onStatus = () => syncQueue(); - const onReconnected = async () => { - console.log("Comfy Sidebar: Server reconnected, syncing state and history"); - await initSessionAndHistory(); - await syncQueue(); - }; - - const onExecutionStart = (e) => { - 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(()=>{}); - scheduleStateSave(); - } - } - - const pid = String(e.detail.prompt_id); - State.currentlyActivePromptId = pid; - const activeWorkspaceWorkflow = app.graph.serialize(); - - if (promptStates.has(pid)) { - const st = promptStates.get(pid); - st.status = "active"; st.progressText = "Processing..."; st.workflow = activeWorkspaceWorkflow; - st.rendered = false; st.startTime = Date.now(); st.duration = null; - } else { - State.sequenceNumber++; - promptStates.set(pid, { - pid: pid, status: "active", images: [], progress: 0, - progressText: "Processing...", timestamp: State.sequenceNumber, - workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null - }); - } - renderDOMFn(); - syncQueue(); - }; - - const onProgress = (e) => { - const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; - if (pid && promptStates.has(pid)) { - const st = promptStates.get(pid); - st.progress = Math.round((e.detail.value / e.detail.max) * 100); - - const cardObj = cardElements.get(pid); - if (cardObj && cardObj.progressBar) { - cardObj.progressBar.style.width = `${st.progress}%`; - if (cardObj.statusText && cardObj.statusText.style.display !== "none") { - const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Processing..."; - cardObj.statusText.textContent = `${nodeName} ${st.progress}%`; - } - } else { - renderDOMFn(); - } - } else if (pid && !promptStates.has(pid)) { - syncQueue(); - } - }; - - const onExecuting = (e) => { - const nodeId = e.detail; - const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true; - - if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) { - const st = promptStates.get(State.currentlyActivePromptId); - if (nodeId) { - const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null; - st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`; - } else { - st.activeNodeName = "Finishing..."; - } - // Don't re-render if user is currently browsing an intermediate/batch submenu - if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) { - renderDOMFn(); - } - } - }; - - const onBPreview = (e) => { - const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active"); - if (activeTasks.length > 0) { - const st = activeTasks[0]; - const newBlobUrl = URL.createObjectURL(e.detail); - - st._oldPreviewBlobUrl = st._previewBlobUrl; - st._previewBlobUrl = newBlobUrl; - st.images = [{ url: newBlobUrl }]; - // Keep preview updated in memory, but don't kick the user out of an active submenu - if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) { - renderDOMFn(); - } - } - }; - - const onExecuted = (e) => { - const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; - if (pid && promptStates.has(pid)) { - const st = promptStates.get(pid); - const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); - if (nodeImgs.length > 0) { - st.images = nodeImgs; - } - const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); - if (nodeTexts.length > 0) { - st.texts = nodeTexts; - } - - if (!st.nodeOutputs) st.nodeOutputs = {}; - st.nodeOutputs[e.detail.node] = e.detail.output; - - renderDOMFn(); - } - }; - - const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, "completed"); - const onExecutionError = (e) => concludeRun(e.detail.prompt_id, "error"); - const onExecutionInterrupted = () => { - Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled")); - syncQueue(); - }; - - api.addEventListener("status", onStatus); - api.addEventListener("reconnected", onReconnected); - api.addEventListener("execution_start", onExecutionStart); - api.addEventListener("progress", onProgress); - api.addEventListener("executing", onExecuting); - api.addEventListener("b_preview", onBPreview); - api.addEventListener("executed", onExecuted); - api.addEventListener("execution_success", onExecutionSuccess); - api.addEventListener("execution_error", onExecutionError); - api.addEventListener("execution_interrupted", onExecutionInterrupted); - - return () => { - api.removeEventListener("status", onStatus); - api.removeEventListener("reconnected", onReconnected); - api.removeEventListener("execution_start", onExecutionStart); - api.removeEventListener("progress", onProgress); - api.removeEventListener("executing", onExecuting); - api.removeEventListener("b_preview", onBPreview); - api.removeEventListener("executed", onExecuted); - api.removeEventListener("execution_success", onExecutionSuccess); - api.removeEventListener("execution_error", onExecutionError); - api.removeEventListener("execution_interrupted", onExecutionInterrupted); - }; -} - -export async function initSessionAndHistory() { - try { - const historyData = await api.getHistory(); - if (!historyData) return; - - const rawItems = []; - if (Array.isArray(historyData.History)) { - historyData.History.forEach(item => { - if (item && item.prompt_id) rawItems.push({ pid: String(item.prompt_id), data: item }); - }); - } else if (typeof historyData === 'object') { - Object.keys(historyData).forEach(pidKey => { - const item = historyData[pidKey]; - rawItems.push({ pid: String(pidKey), data: Array.isArray(item) ? { prompt: item[0], outputs: item[1], status: item[2] } : item }); - }); - } - - rawItems.sort((a, b) => { - const numA = a.data.prompt?.[0] ?? 0; - const numB = b.data.prompt?.[0] ?? 0; - return numA - numB; - }); - - rawItems.forEach(({ pid, data }) => { - const extraData = data.extra_data || data.prompt?.[3] || {}; - const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow); - const outputs = data.outputs || {}; - - const images = getPrimaryOutputImages(outputs, workflow); - const texts = findTextsInOutputs(outputs, workflow); - - const statusStr = data.status?.status_str || "completed"; - const isInterrupted = statusStr === "interrupted" || - (data.status?.messages && JSON.stringify(data.status.messages).toLowerCase().includes("interrupted")); - - const status = (statusStr === "success" || statusStr === "completed") ? "completed" : - (isInterrupted ? "cancelled" : (statusStr === "error" ? "error" : "cancelled")); - - if (images.length === 0 && texts.length === 0 && status === "completed") return; - - if (promptStates.has(pid)) { - const st = promptStates.get(pid); - if (st.status === "active" || st.status === "pending") { - st.status = status; - st.images = images; - st.texts = texts; - st.nodeOutputs = outputs; - st.workflow = workflow || st.workflow; - st.progressText = ""; - st.rendered = false; - } else { - // Restore workflow & outputs from server history on startup - if (!st.workflow && workflow) { - st.workflow = workflow; - } - if ((!st.images || st.images.length === 0) && images.length > 0) { - st.images = images; - } - if ((!st.nodeOutputs || Object.keys(st.nodeOutputs).length === 0) && outputs && Object.keys(outputs).length > 0) { - st.nodeOutputs = outputs; - } - if ((!st.texts || st.texts.length === 0) && texts && texts.length > 0) { - st.texts = texts; - } - } - return; - } - - State.sequenceNumber++; - promptStates.set(pid, { - pid: pid, - status: status, - images: images, - texts: texts, - nodeOutputs: outputs, - workflow: workflow, - progressText: "", - timestamp: State.sequenceNumber, - rendered: true - }); - }); - - pruneHistory(app); - scheduleStateSave(); - await syncQueue(); - } catch (err) { - console.error("Comfy Sidebar: Failed to initialize history from server API", err); - } -} \ No newline at end of file diff --git a/js/queue/executionTracker.js b/js/queue/executionTracker.js new file mode 100644 index 0000000..3becd14 --- /dev/null +++ b/js/queue/executionTracker.js @@ -0,0 +1,152 @@ +import { api } from "/scripts/api.js"; +import { app } from "/scripts/app.js"; +import { store } from "../core/store.js"; +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"; + +export function setupExecutionTracker(onTargetedProgressUpdate) { + const onStatus = () => syncQueue(); + + const onReconnected = async () => { + console.log("Comfy Sidebar: Server reconnected, syncing state and history"); + await initSessionAndHistory(); + await syncQueue(); + }; + + const onExecutionStart = (e) => { + const autoClear = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Auto Clear Interrupted") ?? false; + if (autoClear) { + clearCancelledOrFailed(); + } + + const pid = String(e.detail.prompt_id); + store.setActivePromptId(pid); + + const activeWorkspaceWorkflow = app.graph?.serialize ? app.graph.serialize() : null; + const existing = store.getPrompt(pid); + + if (existing) { + activatePrompt(existing, { workflow: activeWorkspaceWorkflow }); + store.updatePrompt(pid, existing); + } else { + const seq = store.nextSequence(); + const newState = createPromptState(pid, { + status: PromptStatus.ACTIVE, + images: [], + progress: 0, + progressText: "Processing...", + timestamp: seq, + workflow: activeWorkspaceWorkflow, + startTime: Date.now() + }); + store.setPrompt(pid, newState); + } + + syncQueue(); + }; + + const onProgress = (e) => { + const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; + if (pid && store.hasPrompt(pid)) { + const prompt = store.getPrompt(pid); + const percent = Math.round((e.detail.value / e.detail.max) * 100); + updatePromptProgress(prompt, percent); + store.updatePrompt(pid, prompt); + + if (typeof onTargetedProgressUpdate === "function") { + onTargetedProgressUpdate(pid, percent, prompt.activeNodeName); + } + } else if (pid && !store.hasPrompt(pid)) { + syncQueue(); + } + }; + + const onExecuting = (e) => { + const nodeId = e.detail; + const showWorkingNode = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Show Working Node Name") ?? true; + const activePid = store.ui.currentlyActivePromptId; + + if (showWorkingNode && activePid && store.hasPrompt(activePid)) { + const prompt = store.getPrompt(activePid); + if (nodeId) { + const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null; + prompt.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`; + } else { + prompt.activeNodeName = "Finishing..."; + } + store.updatePrompt(activePid, prompt); + + if (typeof onTargetedProgressUpdate === "function") { + onTargetedProgressUpdate(activePid, prompt.progress, prompt.activeNodeName); + } + } + }; + + const onBPreview = (e) => { + const activeTasks = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE); + if (activeTasks.length > 0) { + const prompt = activeTasks[0]; + const newBlobUrl = URL.createObjectURL(e.detail); + + prompt._oldPreviewBlobUrl = prompt._previewBlobUrl; + prompt._previewBlobUrl = newBlobUrl; + prompt.images = [{ url: newBlobUrl }]; + store.updatePrompt(prompt.pid, prompt); + } + }; + + const onExecuted = (e) => { + const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; + if (pid && store.hasPrompt(pid)) { + const prompt = store.getPrompt(pid); + const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow); + if (nodeImgs.length > 0) { + prompt.images = nodeImgs; + } + const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow); + if (nodeTexts.length > 0) { + prompt.texts = nodeTexts; + } + + if (!prompt.nodeOutputs) prompt.nodeOutputs = {}; + prompt.nodeOutputs[e.detail.node] = e.detail.output; + + store.updatePrompt(pid, prompt); + } + }; + + const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, PromptStatus.COMPLETED); + const onExecutionError = (e) => concludeRun(e.detail.prompt_id, PromptStatus.ERROR); + const onExecutionInterrupted = () => { + store.getAllPrompts() + .filter(t => t.status === PromptStatus.ACTIVE) + .forEach(t => concludeRun(t.pid, PromptStatus.CANCELLED)); + syncQueue(); + }; + + api.addEventListener("status", onStatus); + api.addEventListener("reconnected", onReconnected); + api.addEventListener("execution_start", onExecutionStart); + api.addEventListener("progress", onProgress); + api.addEventListener("executing", onExecuting); + api.addEventListener("b_preview", onBPreview); + api.addEventListener("executed", onExecuted); + api.addEventListener("execution_success", onExecutionSuccess); + api.addEventListener("execution_error", onExecutionError); + api.addEventListener("execution_interrupted", onExecutionInterrupted); + + return () => { + api.removeEventListener("status", onStatus); + api.removeEventListener("reconnected", onReconnected); + api.removeEventListener("execution_start", onExecutionStart); + api.removeEventListener("progress", onProgress); + api.removeEventListener("executing", onExecuting); + api.removeEventListener("b_preview", onBPreview); + api.removeEventListener("executed", onExecuted); + api.removeEventListener("execution_success", onExecutionSuccess); + api.removeEventListener("execution_error", onExecutionError); + api.removeEventListener("execution_interrupted", onExecutionInterrupted); + }; +} diff --git a/js/queue/queueNormalizer.js b/js/queue/queueNormalizer.js new file mode 100644 index 0000000..c361dfc --- /dev/null +++ b/js/queue/queueNormalizer.js @@ -0,0 +1,75 @@ +import { parseWorkflow } from "../utils/utils.js"; +import { PromptStatus } from "../core/constants.js"; + +export function normalizeQueueItem(p, defaultIndex = 0) { + let pid = null; + let seq = defaultIndex; + let rawWorkflow = null; + let outputs = {}; + + if (Array.isArray(p)) { + seq = typeof p[0] === "number" ? p[0] : defaultIndex; + pid = p[1] ? String(p[1]) : null; + const extraData = p[3] || {}; + rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null; + outputs = p[4] || {}; + } else if (p && typeof p === "object") { + pid = (p.prompt_id || p.id || p.uuid) ? String(p.prompt_id || p.id || p.uuid) : null; + seq = typeof p.number === "number" ? p.number : (typeof p.prompt_number === "number" ? p.prompt_number : defaultIndex); + const extraData = p.extra_data || {}; + rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null; + outputs = p.outputs || {}; + } + + return { + pid, + seq, + workflow: parseWorkflow(rawWorkflow), + outputs + }; +} + +export function normalizeHistoryItem(pidKey, rawData) { + const pid = String(pidKey); + let item = rawData; + + if (Array.isArray(item)) { + item = { + prompt: item[0], + outputs: item[1] || {}, + status: item[2] || {} + }; + } else if (!item || typeof item !== "object") { + item = { outputs: {}, status: {} }; + } + + const extraData = item.extra_data || item.prompt?.[3] || {}; + const rawWf = extraData.extra_pnginfo?.workflow || extraData.workflow || null; + const workflow = parseWorkflow(rawWf); + const outputs = item.outputs || {}; + const queueNumber = item.prompt?.[0] ?? 0; + + const serverStatus = item.status?.status_str || "completed"; + const isInterrupted = serverStatus === "interrupted" || + (item.status?.messages && JSON.stringify(item.status.messages).toLowerCase().includes("interrupted")); + + let status = PromptStatus.COMPLETED; + if (serverStatus === "success" || serverStatus === "completed") { + status = PromptStatus.COMPLETED; + } else if (isInterrupted) { + status = PromptStatus.CANCELLED; + } else if (serverStatus === "error") { + status = PromptStatus.ERROR; + } else { + status = PromptStatus.CANCELLED; + } + + return { + pid, + queueNumber, + workflow, + outputs, + status, + serverStatus + }; +} diff --git a/js/queue/queueService.js b/js/queue/queueService.js new file mode 100644 index 0000000..6e3465a --- /dev/null +++ b/js/queue/queueService.js @@ -0,0 +1,306 @@ +import { api } from "/scripts/api.js"; +import { app } from "/scripts/app.js"; +import { store } from "../core/store.js"; +import { PromptStatus, StoreEvents } from "../core/constants.js"; +import { createPromptState, activatePrompt, finalizePrompt } from "../core/promptState.js"; +import { normalizeQueueItem, normalizeHistoryItem } from "./queueNormalizer.js"; +import { findImagesInOutputs, findTextsInOutputs, getPrimaryOutputImages, getNodeTitle } from "../utils/utils.js"; + +function extractNodeTitles(outputs, workflow, promptGraph = null) { + const titles = {}; + if (!outputs) return titles; + for (const nodeId in outputs) { + const title = getNodeTitle(nodeId, workflow, promptGraph); + if (title) { + titles[nodeId] = title; + } + } + return titles; +} + +export async function syncQueue() { + try { + const q = await api.getQueue(); + const runningList = q.Running || q.queue_running || []; + const pendingList = q.Pending || q.queue_pending || []; + + const runningIds = new Set(); + const pendingIds = new Set(); + + runningList.forEach((raw, idx) => { + const item = normalizeQueueItem(raw, idx); + if (!item.pid) return; + + runningIds.add(item.pid); + store.setActivePromptId(item.pid); + + const existing = store.getPrompt(item.pid); + const activeWorkflow = item.workflow || (app.graph?.serialize?.() || null); + const titles = extractNodeTitles(item.outputs, activeWorkflow); + + if (!existing) { + const seq = store.nextSequence(); + const images = getPrimaryOutputImages(item.outputs, activeWorkflow); + const texts = findTextsInOutputs(item.outputs, activeWorkflow); + + const newState = createPromptState(item.pid, { + status: PromptStatus.ACTIVE, + images, + texts, + nodeOutputs: item.outputs, + nodeTitles: titles, + progress: 0, + progressText: "Processing...", + timestamp: seq, + workflow: activeWorkflow, + startTime: Date.now() + }); + store.setPrompt(item.pid, newState); + } else { + if (existing.status !== PromptStatus.ACTIVE) { + activatePrompt(existing, { workflow: activeWorkflow, nodeTitles: titles }); + store.updatePrompt(item.pid, existing); + } else { + if (!existing.workflow && activeWorkflow) existing.workflow = activeWorkflow; + if (Object.keys(titles).length > 0) Object.assign(existing.nodeTitles, titles); + store.updatePrompt(item.pid, existing); + } + } + }); + + const normalizedPending = pendingList + .map((raw, idx) => normalizeQueueItem(raw, idx)) + .filter(item => Boolean(item.pid)) + .sort((a, b) => a.seq - b.seq); + + normalizedPending.forEach((item, index) => { + const number = index + 1; + pendingIds.add(item.pid); + + const existing = store.getPrompt(item.pid); + if (!existing) { + const seq = store.nextSequence(); + const newState = createPromptState(item.pid, { + status: PromptStatus.PENDING, + images: [], + progress: 0, + queueNumber: number, + progressText: `Pending... (#${number})`, + timestamp: seq, + workflow: app.graph?.serialize ? app.graph.serialize() : null + }); + store.setPrompt(item.pid, newState); + } else if (existing.status === PromptStatus.PENDING) { + if (existing.queueNumber !== number) { + store.updatePrompt(item.pid, { + queueNumber: number, + progressText: `Pending... (#${number})` + }); + } + } + }); + + for (const prompt of store.getAllPrompts()) { + if (prompt.status === PromptStatus.PENDING && !pendingIds.has(prompt.pid)) { + store.deletePrompt(prompt.pid); + } else if (prompt.status === PromptStatus.ACTIVE && !runningIds.has(prompt.pid)) { + concludeRun(prompt.pid, PromptStatus.CANCELLED); + } + } + + store.emit(StoreEvents.QUEUE_SYNCED, { + pendingCount: pendingIds.size, + runningCount: runningIds.size + }); + } catch (err) { + console.error("Comfy Sidebar: Failed to sync queue state", err); + } +} + +export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED) { + const key = String(pid); + if (!key || !store.hasPrompt(key)) return; + + if (store.ui.currentlyActivePromptId === key) { + store.setActivePromptId(null); + } + + const state = store.getPrompt(key); + finalizePrompt(state, fallbackStatus); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`/history/${key}`); + const hItem = await res.json(); + if (hItem && hItem[key]) { + const norm = normalizeHistoryItem(key, hItem[key]); + + state.workflow = norm.workflow || state.workflow; + state.nodeOutputs = norm.outputs || state.nodeOutputs; + state.status = norm.status; + + const promptGraph = hItem[key].prompt?.[2] || null; + const titles = extractNodeTitles(state.nodeOutputs, state.workflow, promptGraph); + Object.assign(state.nodeTitles, titles); + + const primaryImgs = getPrimaryOutputImages(norm.outputs, state.workflow); + if (primaryImgs.length > 0) state.images = primaryImgs; + + const fetchedTexts = findTextsInOutputs(norm.outputs, state.workflow); + if (fetchedTexts.length > 0) state.texts = fetchedTexts; + break; + } + } catch (err) {} + await new Promise(r => setTimeout(r, 200)); + } + + const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64; + store.pruneHistory(maxItems); + store.updatePrompt(key, state); + syncQueue(); +} + +export async function initSessionAndHistory() { + try { + const historyData = await api.getHistory(); + if (!historyData) return; + + const rawEntries = []; + if (Array.isArray(historyData.History)) { + historyData.History.forEach(item => { + if (item && item.prompt_id) { + rawEntries.push({ pid: String(item.prompt_id), data: item }); + } + }); + } else if (typeof historyData === "object") { + Object.keys(historyData).forEach(pidKey => { + rawEntries.push({ pid: String(pidKey), data: historyData[pidKey] }); + }); + } + + const normalized = rawEntries.map(e => ({ + norm: normalizeHistoryItem(e.pid, e.data), + promptGraph: e.data.prompt?.[2] || null + })); + normalized.sort((a, b) => a.norm.queueNumber - b.norm.queueNumber); + + normalized.forEach(({ norm: item, promptGraph }) => { + const images = getPrimaryOutputImages(item.outputs, item.workflow); + const texts = findTextsInOutputs(item.outputs, item.workflow); + const titles = extractNodeTitles(item.outputs, item.workflow, promptGraph); + + if (images.length === 0 && texts.length === 0 && item.status === PromptStatus.COMPLETED) { + return; + } + + const existing = store.getPrompt(item.pid); + if (existing) { + if (existing.status === PromptStatus.ACTIVE || existing.status === PromptStatus.PENDING) { + existing.status = item.status; + existing.images = images; + existing.texts = texts; + existing.nodeOutputs = item.outputs; + existing.workflow = item.workflow || existing.workflow; + Object.assign(existing.nodeTitles, titles); + existing.progressText = ""; + existing.rendered = false; + store.updatePrompt(item.pid, existing); + } else { + if (!existing.workflow && item.workflow) existing.workflow = item.workflow; + if ((!existing.images || existing.images.length === 0) && images.length > 0) existing.images = images; + if (!existing.nodeOutputs || Object.keys(existing.nodeOutputs).length === 0) { + existing.nodeOutputs = item.outputs; + } + if (!existing.nodeTitles) existing.nodeTitles = {}; + Object.assign(existing.nodeTitles, titles); + if ((!existing.texts || existing.texts.length === 0) && texts && texts.length > 0) existing.texts = texts; + store.updatePrompt(item.pid, existing); + } + return; + } + + const seq = store.nextSequence(); + const newState = createPromptState(item.pid, { + status: item.status, + images, + texts, + nodeOutputs: item.outputs, + nodeTitles: titles, + workflow: item.workflow, + progressText: "", + timestamp: seq, + rendered: true + }); + store.setPrompt(item.pid, newState); + }); + + const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64; + store.pruneHistory(maxItems); + await syncQueue(); + } catch (err) { + console.error("Comfy Sidebar: Failed to initialize history from server API", err); + } +} + +export async function cancelPendingTask(pid) { + try { + await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [pid] }) }); + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to cancel pending task", e); + } +} + +export async function cancelAllPending() { + try { + await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) }); + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to cancel all pending tasks", e); + } +} + +export async function interruptActive() { + try { + await api.interrupt(); + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to interrupt active run", e); + } +} + +export async function deleteHistoryItem(pid) { + try { + store.deletePrompt(pid); + await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [pid] }) }); + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to delete history item", e); + } +} + +export async function clearAllHistory() { + try { + store.clearFinished(); + await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ clear: true }) }); + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to clear all history", e); + } +} + +export async function clearCancelledOrFailed() { + try { + const toDelete = store.clearCancelledOrFailed(); + if (toDelete.length > 0) { + await api.fetchApi("/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delete: toDelete }) + }); + } + await syncQueue(); + } catch (e) { + console.error("Comfy Sidebar: Failed to clear cancelled or failed tasks", e); + } +} \ No newline at end of file diff --git a/js/sidebar.js b/js/sidebar.js index 760149d..0e331e3 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -1,358 +1,222 @@ -import { app } from "/scripts/app.js"; -import { injectStyles } from "./styles.js"; -import { setupDragAndDrop } from "./dragdrop.js"; -import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM, setupScrollListener } from "./ui.js"; -import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js"; -import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./layout.js"; - -let isInitialized = false; -let activeKeydownHandler = null; -let nodeDOMObserver = null; -let cleanupFns = []; - -function findHeaderByText(parent, text) { - if (!text) return null; - const cleanText = text.trim(); - - const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']"); - for (const el of elements) { - if (el.textContent.trim() === cleanText) { - if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) { - return el; - } - } - } - return null; -} - -function syncNodeVueBadge(node, isIgnored) { - const nodeEl = document.querySelector( - `.comfy-node[data-node-id="${node.id}"], ` + - `[data-node-id="${node.id}"], ` + - `[data-id="${node.id}"]` - ); - if (!nodeEl) return; - - const titleTextNode = findHeaderByText(nodeEl, node.title || node.type); - if (!titleTextNode) { - const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge"); - if (badge && !isIgnored) badge.remove(); - return; - } - - const headerEl = titleTextNode.parentNode; - let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge"); - - if (isIgnored) { - if (!badge) { - badge = document.createElement("div"); - badge.className = "comfy-sidebar-ignore-badge"; - badge.innerHTML = ` - - - - - - - `; - Object.assign(badge.style, { - marginLeft: "auto", - marginRight: "6px", - float: "right", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - color: "inherit", - pointerEvents: "none" - }); - headerEl.appendChild(badge); - } - } else { - if (badge) badge.remove(); - } -} - -export function syncAllNodeBadges() { - applySidebarOverride(); - if (!app.graph || !app.graph._nodes) return; - - app.graph._nodes.forEach(node => { - if (!node.properties) node.properties = {}; - const isIgnored = !!node.properties.ignoreInQueue; - - if (isIgnored) { - if (node.boxcolor !== "#ff3333") { - node._oldBoxcolor = node.boxcolor || ""; - node.boxcolor = "#ff3333"; - if (app.graph) app.graph.setDirtyCanvas(true, true); - } - } else { - if (node.boxcolor === "#ff3333") { - node.boxcolor = node._oldBoxcolor || ""; - delete node._oldBoxcolor; - if (app.graph) app.graph.setDirtyCanvas(true, true); - } - } - - syncNodeVueBadge(node, isIgnored); - }); -} - -function setupVueNodeObserver() { - if (nodeDOMObserver) nodeDOMObserver.disconnect(); - - let timeoutId = null; - nodeDOMObserver = new MutationObserver((mutations) => { - let hasNodeMutation = false; - for (const m of mutations) { - for (const node of m.addedNodes) { - if (node.nodeType === 1) { - if (node.hasAttribute?.("data-node-id") || - node.classList?.contains("comfy-node") || - node.querySelector?.('[data-node-id], .comfy-node')) { - hasNodeMutation = true; - break; - } - } - } - if (hasNodeMutation) break; - } - - if (hasNodeMutation) { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - syncAllNodeBadges(); - }, 50); - } - }); - - nodeDOMObserver.observe(document.body, { - childList: true, - subtree: true - }); -} - -function toggleIgnoreActiveNode() { - const canvas = app.canvas; - if (!canvas) return; - - let nodes = []; - if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) { - nodes = Object.values(canvas.selected_nodes); - } else if (canvas.current_active_node) { - nodes = [canvas.current_active_node]; - } - - if (nodes.length === 0) return; - - nodes.forEach(node => { - if (!node.properties) node.properties = {}; - node.properties.ignoreInQueue = !node.properties.ignoreInQueue; - const isIgnored = !!node.properties.ignoreInQueue; - - if (isIgnored) { - if (node.boxcolor !== "#ff3333") { - node._oldBoxcolor = node.boxcolor || ""; - node.boxcolor = "#ff3333"; - } - } else { - if (node.boxcolor === "#ff3333") { - node.boxcolor = node._oldBoxcolor || ""; - delete node._oldBoxcolor; - } - } - - syncNodeVueBadge(node, isIgnored); - }); - - if (app.graph) { - if (app.graph.change) app.graph.change(); // Marks workflow as modified so ComfyUI autosaves it - app.graph.setDirtyCanvas(true, true); - } - if (app.canvas) app.canvas.setDirty(true, true); - - renderDOM(); // Immediately update sidebar buttons without needing a reload -} - -app.registerExtension({ - name: "ComfySidebar.ClassicRestore", - - init() { - 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.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.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false }); - - const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"]; - sidebarTabs.forEach(tab => { - app.ui.settings.addSetting({ - id: `Comfy Sidebar.Hide Junk.${tab}`, - name: `Hide Tab: ${tab}`, - type: "boolean", - defaultValue: false, - onChange: () => { - setTimeout(() => { syncClassicLayout(); }, 0); - } - }); - }); - - app.ui.settings.addSetting({ - id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab", - name: "Replace the stock Job History sidebar with Comfy Queue", - type: "boolean", - defaultValue: false, - onChange: (value) => { - if (isInitialized && app.ui && app.ui.settings) { - syncStockHistoryAndProgressSettings(value); - } - setTimeout(() => { syncClassicLayout(); }, 0); - } - }); - - 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(() => { syncClassicLayout(); }, 0); - } - }); - - 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) => { - if (isInitialized) { - applyClassicLayout(value, true); - } - } - }); - }, - - commands: [ - { - id: "ComfySidebar.ToggleSidebar", - label: "Toggle Comfy Queue Sidebar", - function: () => { - const ourBtn = findOurSidebarButton(); - if (ourBtn) ourBtn.click(); - } - }, - { - id: "ComfySidebar.ToggleIgnoreNode", - label: "Toggle Ignore Selected Node(s) in Queue", - function: () => toggleIgnoreActiveNode() - } - ], - - nodeCreated(node) { - if (node && node.properties?.ignoreInQueue) { - node.boxcolor = "#ff3333"; - syncNodeVueBadge(node, true); - } - }, - - afterConfigureGraph() { - requestAnimationFrame(() => { - syncAllNodeBadges(); - renderDOM(); - }); - }, - - async setup() { - if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return; - - const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false; - applyClassicLayout(isClassicLayoutEnabled, false); - - const isHistoryOverrideEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false; - if (isHistoryOverrideEnabled) { - syncStockHistoryAndProgressSettings(true); - } - - isInitialized = true; - - setupPropertiesPanelToggleFix(); - - setSyncQueue(syncQueue); - setUIDependencies(renderDOM, updateSidebarBadge); - - injectStyles(); - - // Register disposable subsystems - cleanupFns.push(setupDragAndDrop()); - cleanupFns.push(setupScrollListener()); - cleanupFns.push(setupApiListeners()); - - const sidebarContainer = setupSidebarUI(); - - await initSessionAndHistory(); - - setupVueNodeObserver(); - - if (activeKeydownHandler) { - document.removeEventListener("keydown", activeKeydownHandler, true); - activeKeydownHandler = null; - } - - activeKeydownHandler = (e) => { - const activeEl = document.activeElement; - if (activeEl && ( - activeEl.tagName === "INPUT" || - activeEl.tagName === "TEXTAREA" || - activeEl.isContentEditable || - activeEl.tagName === "SELECT" - )) return; - - if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - const ourBtn = findOurSidebarButton(); - if (ourBtn) ourBtn.click(); - } - - if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - toggleIgnoreActiveNode(); - } - }; - - document.addEventListener("keydown", activeKeydownHandler, true); - - applySidebarOverride(); - - app.extensionManager.registerSidebarTab({ - id: "classic-comfy-sidebar", - icon: "pi pi-images", - title: "Queue", - tooltip: "Comfy Queue (Q)", - type: "custom", - render: (el) => { el.appendChild(sidebarContainer); } - }); - }, - - destroy() { - for (const fn of cleanupFns.splice(0)) { - try { if (typeof fn === "function") fn(); } catch (e) {} - } - destroyLayoutFix(); - - if (nodeDOMObserver) { - nodeDOMObserver.disconnect(); - nodeDOMObserver = null; - } - - if (activeKeydownHandler) { - document.removeEventListener("keydown", activeKeydownHandler, true); - activeKeydownHandler = null; - } - - if (app.extensionManager?.unregisterSidebarTab) { - app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar"); - } - } -}); \ No newline at end of file +import { app } from "/scripts/app.js"; +import { SettingIds } from "./core/constants.js"; +import { injectStyles } from "./utils/styles.js"; +import { setupDragAndDrop } from "./utils/dragdrop.js"; +import { findOurSidebarButton } from "./comfy/adapter.js"; +import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js"; +import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js"; +import { initSessionAndHistory, syncQueue } from "./queue/queueService.js"; +import { setupExecutionTracker } from "./queue/executionTracker.js"; +import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js"; +import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js"; + +let isInitialized = false; +let activeKeydownHandler = null; +let cleanupFns = []; + +function registerSettings() { + app.ui.settings.addSetting({ + id: SettingIds.GRID_COLUMNS_THRESHOLD, + name: "Width Threshold for Queue Columns (px)", + type: "number", + defaultValue: 350 + }); + + app.ui.settings.addSetting({ + id: SettingIds.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: SettingIds.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: SettingIds.AUTO_CLEAR_INTERRUPTED, + name: "Auto-clear cancelled & failed jobs on new generation", + type: "boolean", + defaultValue: false + }); + + const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"]; + sidebarTabs.forEach(tab => { + app.ui.settings.addSetting({ + id: `Comfy Sidebar.Hide Junk.${tab}`, + name: `Hide Tab: ${tab}`, + type: "boolean", + defaultValue: false, + onChange: () => { + setTimeout(() => { syncClassicLayout(); }, 0); + } + }); + }); + + app.ui.settings.addSetting({ + id: SettingIds.OVERRIDE_STOCK_HISTORY, + name: "Replace the stock Job History sidebar with Comfy Queue", + type: "boolean", + defaultValue: false, + onChange: (value) => { + if (isInitialized && app.ui?.settings) { + syncStockHistoryAndProgressSettings(value); + } + setTimeout(() => { syncClassicLayout(); }, 0); + } + }); + + app.ui.settings.addSetting({ + id: SettingIds.HIDE_GRAPH_BUTTON, + name: "Hide floating 'Graph' (Workflow/Node Map) button", + type: "boolean", + defaultValue: false, + onChange: () => { + setTimeout(() => { syncClassicLayout(); }, 0); + } + }); + + app.ui.settings.addSetting({ + id: SettingIds.CLASSIC_LAYOUT, + name: "Places the controls and the open workflow tabs on a single unified top bar", + type: "boolean", + defaultValue: false, + onChange: (value) => { + if (isInitialized) { + applyClassicLayout(value, true); + } + } + }); +} + +function setupKeydownShortcuts() { + activeKeydownHandler = (e) => { + const activeEl = document.activeElement; + if (activeEl && ( + activeEl.tagName === "INPUT" || + activeEl.tagName === "TEXTAREA" || + activeEl.isContentEditable || + activeEl.tagName === "SELECT" + )) return; + + if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const ourBtn = findOurSidebarButton(); + if (ourBtn) ourBtn.click(); + } + + if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + toggleIgnoreActiveNode(() => renderSidebar()); + } + }; + + document.addEventListener("keydown", activeKeydownHandler, true); +} + +app.registerExtension({ + name: "ComfySidebar.ClassicRestore", + + init() { + registerSettings(); + }, + + commands: [ + { + id: "ComfySidebar.ToggleSidebar", + label: "Toggle Comfy Queue Sidebar", + function: () => { + const ourBtn = findOurSidebarButton(); + if (ourBtn) ourBtn.click(); + } + }, + { + id: "ComfySidebar.ToggleIgnoreNode", + label: "Toggle Ignore Selected Node(s) in Queue", + function: () => toggleIgnoreActiveNode(() => renderSidebar()) + } + ], + + nodeCreated(node) { + if (node && node.properties?.ignoreInQueue) { + node.boxcolor = "#ff3333"; + syncAllNodeBadges(); + } + }, + + afterConfigureGraph() { + requestAnimationFrame(() => { + syncAllNodeBadges(); + renderSidebar(); + }); + }, + + async setup() { + if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return; + + const isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false; + applyClassicLayout(isClassicLayoutEnabled, false); + + const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false; + if (isHistoryOverrideEnabled) { + syncStockHistoryAndProgressSettings(true); + } + + isInitialized = true; + + setupPropertiesPanelToggleFix(); + injectStyles(); + + cleanupFns.push(setupDragAndDrop()); + cleanupFns.push(setupVueNodeObserver()); + + // Setup execution tracker with targeted fast-path update for active card + cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => { + const cardObj = cardPool.get(pid); + if (cardObj) { + const showWorkingNode = app.ui.settings.getSettingValue(SettingIds.SHOW_WORKING_NODE_NAME) ?? true; + updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode); + } else { + renderSidebar(); + } + })); + + const sidebarContainer = setupSidebarView(); + + await initSessionAndHistory(); + setupKeydownShortcuts(); + + syncAllNodeBadges(); + + app.extensionManager.registerSidebarTab({ + id: "classic-comfy-sidebar", + icon: "pi pi-images", + title: "Queue", + tooltip: "Comfy Queue (Q)", + type: "custom", + render: (el) => { el.appendChild(sidebarContainer); } + }); + }, + + destroy() { + for (const fn of cleanupFns.splice(0)) { + try { if (typeof fn === "function") fn(); } catch (e) {} + } + destroyLayoutFix(); + teardownSidebarView(); + + if (activeKeydownHandler) { + document.removeEventListener("keydown", activeKeydownHandler, true); + activeKeydownHandler = null; + } + + if (app.extensionManager?.unregisterSidebarTab) { + app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar"); + } + } +}); diff --git a/js/state.js b/js/state.js deleted file mode 100644 index e230b67..0000000 --- a/js/state.js +++ /dev/null @@ -1,121 +0,0 @@ -export const promptStates = new Map(); -export const cardElements = new Map(); - -export const State = { - currentSearchQuery: "", - sequenceNumber: 0, - sidebarContainer: null, - cardStack: null, - submenuStack: null, - currentlyActivePromptId: null, - activeSubmenuPromptId: null, - activeSubmenuBatchImages: null -}; - -let saveTimer = null; -export function scheduleStateSave(delay = 250) { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(() => { - saveTimer = null; - saveStatesToLocalStorage(); - }, delay); -} - -export function deletePromptState(pid) { - const key = String(pid); - const state = promptStates.get(key); - if (state) { - if (state._previewBlobUrl) { - try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {} - } - if (state._oldPreviewBlobUrl) { - try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {} - } - } - promptStates.delete(key); - cardElements.delete(key); -} - -export function pruneHistory(app) { - const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64; - const tasks = Array.from(promptStates.entries()) - .filter(([pid, state]) => state.status !== "pending" && state.status !== "active"); - - tasks.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0)); - - if (tasks.length > maxItems) { - const deleteCount = tasks.length - maxItems; - for (let i = 0; i < deleteCount; i++) { - const [pid] = tasks[i]; - deletePromptState(pid); - } - } -} - -export function saveStatesToLocalStorage() { - try { - let serializable = []; - for (const [pid, state] of promptStates.entries()) { - const cleanedImages = (state.images || []).map(img => { - if (img.url && img.url.startsWith("blob:")) return null; - return img; - }).filter(Boolean); - - const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed"; - - serializable.push({ - pid: String(state.pid), - status: state.status, - images: cleanedImages, - texts: state.texts || [], - nodeOutputs: state.nodeOutputs, - workflow: includeWorkflow ? state.workflow : null, - progress: state.progress || 0, - queueNumber: state.queueNumber, - progressText: state.progressText || "", - timestamp: state.timestamp, - activeNodeName: state.activeNodeName || "", - rendered: state.rendered || false, - startTime: state.startTime, - endTime: state.endTime, - duration: state.duration - }); - } - - if (serializable.length === 0) { - localStorage.removeItem("comfy_sidebar_prompt_states"); - return; - } - - serializable.sort((a, b) => a.timestamp - b.timestamp); - - while (serializable.length > 0) { - try { - localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable)); - break; - } catch (e) { - if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) { - serializable.shift(); - } else { - break; - } - } - } - } catch (e) {} -} - -export function loadStatesFromLocalStorage() { - try { - const data = localStorage.getItem("comfy_sidebar_prompt_states"); - if (data) { - const list = JSON.parse(data); - list.forEach(state => { - const key = String(state.pid); - promptStates.set(key, state); - if (state.timestamp > State.sequenceNumber) State.sequenceNumber = state.timestamp; - }); - } - } catch (e) {} -} - -loadStatesFromLocalStorage(); \ No newline at end of file diff --git a/js/ui.js b/js/ui.js deleted file mode 100644 index 39dafb2..0000000 --- a/js/ui.js +++ /dev/null @@ -1,1967 +0,0 @@ -import { app } from "/scripts/app.js"; -import { api } from "/scripts/api.js"; -import { State, promptStates, cardElements, scheduleStateSave, deletePromptState } from "./state.js"; -import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs, extractWorkflowFromPng } from "./utils.js"; -import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js"; - -async function copyImageToClipboard(src) { - try { - const res = await fetch(src); - let blob = await res.blob(); - if (blob.type !== "image/png") { - const img = new Image(); - img.crossOrigin = "anonymous"; - await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = src; }); - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth || img.width; - canvas.height = img.naturalHeight || img.height; - canvas.getContext("2d").drawImage(img, 0, 0); - blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); - } - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); - return true; - } catch (err) { - console.error("Comfy Sidebar: Failed to copy image to clipboard", err); - return false; - } -} - -export let syncQueueFn = async () => {}; -export function setSyncQueue(fn) { syncQueueFn = fn; } - -let currentlyPlayingAudio = null; -export function stopAllAudioPlayback() { - if (currentlyPlayingAudio) { - currentlyPlayingAudio.pause(); - currentlyPlayingAudio.currentTime = 0; - if (currentlyPlayingAudio._onResetUI) currentlyPlayingAudio._onResetUI(); - currentlyPlayingAudio = null; - } -} - -function removeImageFromNodeOutputs(nodeOutputs, targetImg) { - if (!nodeOutputs || !targetImg || !targetImg.filename) return; - - const scanAndRemove = (obj) => { - if (!obj || typeof obj !== "object") return; - for (const key in obj) { - const val = obj[key]; - if (Array.isArray(val)) { - for (let i = val.length - 1; i >= 0; i--) { - const item = val[i]; - if (item && typeof item === "object") { - if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) { - val.splice(i, 1); - } else { - scanAndRemove(item); - } - } else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) { - val.splice(i, 1); - } - } - } else if (typeof val === "object") { - scanAndRemove(val); - } - } - }; - - for (const nodeId in nodeOutputs) { - scanAndRemove(nodeOutputs[nodeId]); - } -} - -async function openFileOrFolder(img) { - if (!img) return; - - // 1. Try server-side open-folder endpoint if backend endpoint exists - try { - const res = await fetch("/comfy-sidebar/open-folder", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: img.filename, - subfolder: img.subfolder || "", - type: img.type || "output" - }) - }); - if (res.ok) return; - } catch (e) {} - - // 2. Fallback: open / view / download the file - const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - const a = document.createElement("a"); - a.href = src; - a.target = "_blank"; - a.download = img.filename || "file"; - document.body.appendChild(a); - a.click(); - a.remove(); -} - -function resetAllCardHoverStates() { - for (const cardObj of cardElements.values()) { - if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none"; - if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none"; - } -} - -let scrollToTopBtnEl = null; - -function getScrollContainer() { - if (!State.cardStack) return null; - return State.cardStack.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') - || State.cardStack.parentElement - || State.cardStack; -} - -function updateScrollTopBtnVisibility() { - if (!scrollToTopBtnEl || !State.sidebarContainer || !State.sidebarContainer.isConnected) return; - const scrollEl = getScrollContainer(); - if (scrollEl) { - const viewportEl = scrollEl.parentElement || State.sidebarContainer; - if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) { - if (window.getComputedStyle(viewportEl).position === "static") { - viewportEl.style.position = "relative"; - } - viewportEl.appendChild(scrollToTopBtnEl); - } - - if (scrollEl.scrollTop > 200) { - scrollToTopBtnEl.style.display = "flex"; - } else { - scrollToTopBtnEl.style.display = "none"; - } - } -} - -const scrollListener = () => { - if (State.sidebarContainer && State.sidebarContainer.isConnected) { - updateScrollTopBtnVisibility(); - } -}; - -const handleGlobalKeyDown = (e) => { - if (e.key === "Control" || e.key === "Meta") { - document.body.classList.add("comfy-sidebar-ctrl-active"); - } -}; - -const handleGlobalKeyUp = (e) => { - if (e.key === "Control" || e.key === "Meta") { - document.body.classList.remove("comfy-sidebar-ctrl-active"); - } -}; - -const handleGlobalBlur = () => { - document.body.classList.remove("comfy-sidebar-ctrl-active"); -}; - -window.addEventListener("keydown", handleGlobalKeyDown); -window.addEventListener("keyup", handleGlobalKeyUp); -window.addEventListener("blur", handleGlobalBlur); - -export function setupScrollListener() { - document.addEventListener("scroll", scrollListener, { capture: true, passive: true }); - return () => { - document.removeEventListener("scroll", scrollListener, { capture: true, passive: true }); - }; -} - -function findNodeIdForImage(state, img) { - if (!state || !state.nodeOutputs || !img) return null; - for (const nodeId in state.nodeOutputs) { - const out = state.nodeOutputs[nodeId]; - for (const key in out) { - const val = out[key]; - if (Array.isArray(val)) { - if (val.some(i => i && typeof i === 'object' && i.filename === img.filename)) { - return nodeId; - } - } else if (val && typeof val === 'object' && val.filename === img.filename) { - return nodeId; - } - } - } - return null; -} - -export const updateSidebarBadge = (count) => { - const icons = document.querySelectorAll('.pi-images'); - icons.forEach(icon => { - const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]'); - if (btn) { - let badge = btn.querySelector('.comfy-sidebar-badge'); - if (count > 0) { - if (!badge) { - btn.style.position = 'relative'; - badge = document.createElement('div'); - badge.className = 'comfy-sidebar-badge'; - Object.assign(badge.style, { - position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff', - borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex', - alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none' - }); - btn.appendChild(badge); - } - badge.textContent = count > 99 ? '99+' : count; - } else if (badge) { - badge.remove(); - } - } - }); -}; - -export const findOurSidebarButton = () => { - const icon = document.querySelector('.pi-images'); - return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null; -}; - -export const findStandardQueueButton = () => { - for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) { - const icon = document.querySelector(iconSelector); - if (icon) { - const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]'); - if (btn && !btn.querySelector('.pi-images')) return btn; - } - } - const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]'); - for (const btn of buttons) { - const title = btn.title || btn.getAttribute('aria-label') || ''; - if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) && !btn.querySelector('.pi-images') && !btn.id?.includes('classic-comfy-sidebar')) return btn; - } - return null; -}; - -export const applySidebarOverride = () => { - const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false; - const stdBtn = findStandardQueueButton(); - const ourBtn = findOurSidebarButton(); - - if (stdBtn) { - if (!stdBtn._originalDisplay) stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block"; - if (overrideStock) { - stdBtn.style.setProperty("display", "none", "important"); - if (!stdBtn._overrideClickListener) { - stdBtn._overrideClickListener = (e) => { - e.preventDefault(); e.stopPropagation(); - const b = findOurSidebarButton(); - if (b) b.click(); - }; - stdBtn.addEventListener('click', stdBtn._overrideClickListener, true); - } - if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) stdBtn.parentNode.insertBefore(ourBtn, stdBtn); - } else { - stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay); - if (stdBtn._overrideClickListener) { - stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true); - stdBtn._overrideClickListener = null; - } - if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) { - stdBtn.parentNode.appendChild(ourBtn); - } - } - } -}; - -export function setupSidebarUI() { - State.sidebarContainer = document.createElement("div"); - Object.assign(State.sidebarContainer.style, { - display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box", - background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative" - }); - - const header = document.createElement("div"); - Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" }); - - const standardHeader = document.createElement("div"); - Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" }); - - const titleGroup = document.createElement("div"); - Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" }); - - const searchIcon = document.createElement("span"); - searchIcon.className = "pi pi-search comfy-sidebar-icon-btn"; - searchIcon.title = "Search History"; - - const title = document.createElement("h3"); - title.textContent = "Queue"; - Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" }); - - titleGroup.appendChild(searchIcon); - titleGroup.appendChild(title); - standardHeader.appendChild(titleGroup); - - const actionsGroup = document.createElement("div"); - Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" }); - - const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => { - const btn = document.createElement("button"); - btn.className = `${iconClass} comfy-sidebar-header-btn`; - btn.title = tooltip; - - 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.addEventListener("click", 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); - } else { - reset(); - await onClickFn(); - } - }); - return btn; - }; - - 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); - } - } - if (toDelete.length > 0) { - try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }); } catch (err) {} - scheduleStateSave(); - renderDOM(); - } - }); - - 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", body: JSON.stringify({ clear: true }) }); } catch (err) {} - scheduleStateSave(); - 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" }); - const searchInputIcon = document.createElement("span"); - searchInputIcon.className = "pi pi-search"; - Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" }); - const searchInput = document.createElement("input"); - Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." }); - Object.assign(searchInput.style, { flex: "1", background: "transparent", border: "none", outline: "none", color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0" }); - const clearSearchBtn = document.createElement("span"); - clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn"; - clearSearchBtn.title = "Clear & Close Search"; - Object.assign(clearSearchBtn.style, { marginLeft: "6px" }); - - searchContainer.appendChild(searchInputIcon); searchContainer.appendChild(searchInput); searchContainer.appendChild(clearSearchBtn); - header.appendChild(standardHeader); header.appendChild(searchContainer); State.sidebarContainer.appendChild(header); - - searchIcon.addEventListener("click", (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); }); - const closeSearch = () => { searchInput.value = ""; State.currentSearchQuery = ""; searchContainer.style.display = "none"; standardHeader.style.display = "flex"; renderDOM(); }; - clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); }); - searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); }); - searchInput.addEventListener("input", () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); }); - - State.cardStack = document.createElement("div"); - Object.assign(State.cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" }); - State.sidebarContainer.appendChild(State.cardStack); - - scrollToTopBtnEl = document.createElement("button"); - scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn"; - scrollToTopBtnEl.title = "Scroll to Top"; - - scrollToTopBtnEl.addEventListener("click", (e) => { - e.stopPropagation(); - const scrollEl = getScrollContainer(); - if (scrollEl) { - scrollEl.scrollTo({ top: 0, behavior: "smooth" }); - } - }); - - State.sidebarContainer.appendChild(scrollToTopBtnEl); - - State.sidebarContainer.addEventListener("click", (e) => { - if (State.activeSubmenuPromptId || State.activeSubmenuBatchImages) { - if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return; - State.activeSubmenuPromptId = null; - State.activeSubmenuBatchImages = null; - resetAllCardHoverStates(); - 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))); - if (State.cardStack) { - State.cardStack.style.columnCount = cols.toString(); - State.cardStack.style.columnGap = cols > 1 ? "12px" : "0"; - } - }).observe(State.sidebarContainer); - - setInterval(() => { - if (State.currentlyActivePromptId === null) return; - for (const [pid, state] of promptStates.entries()) { - if (state.status === "active" && state.startTime) { - const cardObj = cardElements.get(pid); - if (cardObj && cardObj.timerEl) cardObj.timerEl.textContent = ((Date.now() - state.startTime) / 1000).toFixed(2) + "s"; - } - } - }, 250); - - return State.sidebarContainer; -} - -function syncCardButtonVisibility(cardObj, state) { - if (!cardObj) return; - - // Pending and currently running cards should never display hover action panels - const isPendingOrActive = state.status === "pending" || state.status === "active"; - if (isPendingOrActive) { - if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important"); - if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important"); - return; - } else { - if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display"); - if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display"); - } - - const isCompleted = state.status === "completed"; - const hasRealImages = isCompleted && state.images && state.images.length > 0 && !state.images.some(img => img.url && img.url.startsWith("blob:")); - const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null; - const isPng = currentImg && (currentImg.filename || "").toLowerCase().endsWith(".png"); - - if (cardObj.btnImg) { - if (hasRealImages) { - cardObj.btnImg.style.removeProperty("display"); - 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 || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - a.download = img.filename || "output"; - a.click(); - }); - }; - } else { - cardObj.btnImg.style.setProperty("display", "none", "important"); - } - } - - if (cardObj.btnCopy) { - if (hasRealImages && currentImg) { - cardObj.btnCopy.style.removeProperty("display"); - cardObj.btnCopy.style.display = "inline-flex"; - cardObj.btnCopy.onclick = async (ev) => { - ev.stopPropagation(); - const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`; - const success = await copyImageToClipboard(src); - if (success) { - cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn"; - cardObj.btnCopy.style.color = "#4ade80"; - setTimeout(() => { - cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; - cardObj.btnCopy.style.color = ""; - }, 1500); - } - }; - } else { - cardObj.btnCopy.style.setProperty("display", "none", "important"); - } - } - - if (cardObj.btnJson) { - if (state.workflow || isPng) { - cardObj.btnJson.style.removeProperty("display"); - cardObj.btnJson.style.display = "inline-flex"; - cardObj.btnJson.onclick = async (ev) => { - ev.stopPropagation(); - let wf = state.workflow; - if (!wf && currentImg) { - const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`; - wf = await extractWorkflowFromPng(src); - if (wf) state.workflow = wf; - } - if (!wf) { - alert("No workflow metadata found in this file."); - return; - } - const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `workflow_${state.pid || "comfy"}.json`; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 1500); - }; - } else { - cardObj.btnJson.style.setProperty("display", "none", "important"); - } - } - - if (cardObj.btnDel) { - cardObj.btnDel.style.removeProperty("display"); - cardObj.btnDel.style.display = "inline-flex"; - } - - if (cardObj.btnFocus) { - const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null; - if (nodeId) { - cardObj.btnFocus.style.removeProperty("display"); - cardObj.btnFocus.style.display = "inline-flex"; - cardObj.btnFocus.onclick = (ev) => { - ev.stopPropagation(); - const node = app.graph.getNodeById(Number(nodeId)); - if (node) { - app.canvas.centerOnNode(node); - app.canvas.selectNode(node); - } - }; - } else { - cardObj.btnFocus.style.setProperty("display", "none", "important"); - } - } - - if (cardObj.leftHoverBtn) { - const outputs = getRunOutputs(state.nodeOutputs, state.workflow); - const validOutputs = outputs.filter(o => o.images && o.images.length > 0); - - // Signatures of images already showing on the primary card - 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); - } - } - - // Show intermediate button ONLY if there are genuine distinct/extra outputs - const hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0; - - if (hasIntermediates) { - cardObj.leftHoverBtn.style.removeProperty("display"); - cardObj.leftHoverBtn.style.display = "inline-flex"; - cardObj.leftHoverBtn.onclick = (ev) => { - ev.stopPropagation(); - if (!State.activeSubmenuBatchImages && !State.activeSubmenuPromptId) { - const scrollEl = getScrollContainer(); - if (scrollEl) State.mainQueueScrollTop = scrollEl.scrollTop; - } - State.activeSubmenuPromptId = state.pid; - resetAllCardHoverStates(); - renderDOM(); - }; - } else { - cardObj.leftHoverBtn.style.setProperty("display", "none", "important"); - } - } -} - -function render3DCardPreview(cardObj, wrapper, src, img, state) { - let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper"); - const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - - const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || []; - const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null; - const bgImgSrc = lastImageAsset - ? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`) - : null; - - if (!preview3D) { - wrapper.innerHTML = ""; - preview3D = document.createElement("div"); - preview3D.className = "comfy-sidebar-3d-wrapper"; - - if (bgImgSrc) { - const bgImg = document.createElement("img"); - bgImg.className = "comfy-sidebar-3d-preview-img"; - bgImg.src = bgImgSrc; - bgImg.alt = "3D Thumbnail Preview"; - preview3D.appendChild(bgImg); - } - - const overlay = document.createElement("div"); - overlay.className = "comfy-sidebar-3d-overlay"; - - const badge = document.createElement("span"); - badge.className = "comfy-sidebar-3d-badge"; - const ext = (img.filename || src).split('.').pop().toUpperCase(); - badge.textContent = ext || "3D"; - - const icon = document.createElement("span"); - icon.className = "pi pi-box comfy-sidebar-3d-icon"; - - const title = document.createElement("span"); - title.className = "comfy-sidebar-3d-title"; - title.textContent = img.filename || "3D Model"; - - overlay.append(badge, icon, title); - preview3D.appendChild(overlay); - wrapper.appendChild(preview3D); - } else { - const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img"); - if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) { - bgImg.src = bgImgSrc; - } else if (!bgImg && bgImgSrc) { - const newBgImg = document.createElement("img"); - newBgImg.className = "comfy-sidebar-3d-preview-img"; - newBgImg.src = bgImgSrc; - newBgImg.alt = "3D Thumbnail Preview"; - preview3D.insertBefore(newBgImg, preview3D.firstChild); - } - const title = preview3D.querySelector(".comfy-sidebar-3d-title"); - if (title) title.textContent = img.filename || "3D Model"; - } - - cardObj.firstImgElement = preview3D; - - preview3D.onclick = (ev) => { - ev.stopPropagation(); - if (ev.ctrlKey || ev.metaKey) { - openFileOrFolder(img); - return; - } - showFullscreenPreview([fullUrl], ev.shiftKey); - }; - - preview3D.setAttribute("draggable", "true"); - preview3D.ondragstart = (e) => { - e.stopPropagation(); - const filename = img.filename || "model.glb"; - const mimeType = "application/octet-stream"; - - try { - e.dataTransfer.setData("text/uri-list", fullUrl); - e.dataTransfer.setData("text/plain", fullUrl); - e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); - if (state && state.workflow) { - e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); - } - } catch (err) {} - e.dataTransfer.effectAllowed = "copy"; - }; -} - -function renderAudioCardPreview(cardObj, wrapper, src, img, state) { - let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper"); - const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - const filename = img.filename || getFilenameFromUrl(src) || "audio.wav"; - const ext = filename.split('.').pop().toUpperCase(); - - const playIconSvg = ``; - const stopIconSvg = ``; - - if (!previewAudio) { - wrapper.innerHTML = ""; - previewAudio = document.createElement("div"); - previewAudio.className = "comfy-sidebar-audio-wrapper"; - - const topRow = document.createElement("div"); - Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" }); - - const title = document.createElement("span"); - title.className = "comfy-sidebar-audio-title"; - title.textContent = filename; - - const badge = document.createElement("span"); - badge.className = "comfy-sidebar-audio-badge"; - badge.textContent = ext || "AUDIO"; - - topRow.append(title, badge); - - const centerArea = document.createElement("div"); - Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" }); - - const soundwave = document.createElement("div"); - soundwave.className = "comfy-sidebar-soundwave-container"; - soundwave.style.width = "100%"; - const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12]; - barHeights.forEach((h, i) => { - const bar = document.createElement("div"); - bar.className = "comfy-sidebar-soundwave-bar"; - bar.style.height = `${h}px`; - bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`; - soundwave.appendChild(bar); - }); - - const playBtn = document.createElement("button"); - Object.assign(playBtn.style, { - position: "absolute", width: "36px", height: "36px", borderRadius: "50%", - background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)", - color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center", - justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5", - transition: "all 0.15s ease", outline: "none" - }); - playBtn.innerHTML = playIconSvg; - - centerArea.append(soundwave, playBtn); - - const bottomRow = document.createElement("div"); - Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" }); - - const scrubber = document.createElement("div"); - Object.assign(scrubber.style, { - width: "100%", height: "4px", background: "#334155", borderRadius: "2px", - position: "relative", cursor: "pointer" - }); - const scrubberFill = document.createElement("div"); - Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" }); - scrubber.appendChild(scrubberFill); - - const timeLabel = document.createElement("div"); - Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" }); - timeLabel.textContent = "0:00 / 0:00"; - - bottomRow.append(scrubber, timeLabel); - - const audioEl = document.createElement("audio"); - audioEl.src = fullUrl; - audioEl.preload = "metadata"; - - const formatTime = (t) => { - const m = Math.floor(t / 60); - const s = Math.floor(t % 60); - return `${m}:${s < 10 ? '0' : ''}${s}`; - }; - - const resetAudioUI = () => { - playBtn.innerHTML = playIconSvg; - previewAudio.classList.remove("playing"); - scrubberFill.style.width = "0%"; - timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; - }; - - audioEl._onResetUI = resetAudioUI; - - audioEl.onloadedmetadata = () => { - timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; - }; - - audioEl.ontimeupdate = () => { - if (audioEl.duration > 0) { - const percent = (audioEl.currentTime / audioEl.duration) * 100; - scrubberFill.style.width = `${percent}%`; - timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`; - } - }; - - audioEl.onended = () => { - resetAudioUI(); - if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null; - }; - - const togglePlay = (e) => { - e.stopPropagation(); - - if (isAudioViewerOpen()) { - stopAllAudioPlayback(); - showFullscreenPreview([fullUrl]); - return; - } - - if (audioEl.paused) { - stopAllAudioPlayback(); - currentlyPlayingAudio = audioEl; - audioEl.play().catch(()=>{}); - playBtn.innerHTML = stopIconSvg; - previewAudio.classList.add("playing"); - } else { - audioEl.pause(); - audioEl.currentTime = 0; - resetAudioUI(); - if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null; - } - }; - - playBtn.onclick = togglePlay; - - playBtn.onmouseenter = () => { - playBtn.style.background = "#9333ea"; - playBtn.style.borderColor = "#c084fc"; - playBtn.style.transform = "scale(1.08)"; - }; - playBtn.onmouseleave = () => { - playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)"; - playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)"; - playBtn.style.transform = "scale(1.0)"; - }; - - scrubber.onclick = (e) => { - e.stopPropagation(); - const rect = scrubber.getBoundingClientRect(); - const pos = (e.clientX - rect.left) / rect.width; - if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration; - }; - - previewAudio.append(topRow, centerArea, bottomRow, audioEl); - wrapper.appendChild(previewAudio); - - previewAudio.onclick = (e) => { - if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return; - stopAllAudioPlayback(); - if (e.ctrlKey || e.metaKey) { - openFileOrFolder(img); - return; - } - showFullscreenPreview([fullUrl]); - }; - } - - cardObj.firstImgElement = previewAudio; - - previewAudio.setAttribute("draggable", "true"); - previewAudio.ondragstart = (e) => { - e.stopPropagation(); - const mimeType = "audio/wav"; - try { - e.dataTransfer.setData("text/uri-list", fullUrl); - e.dataTransfer.setData("text/plain", fullUrl); - e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); - if (state && state.workflow) { - e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); - } - } catch (err) {} - e.dataTransfer.effectAllowed = "copy"; - }; -} - -function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) { - let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper"); - const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - const filename = img.filename || getFilenameFromUrl(src) || "output_file"; - const ext = (filename.split('.').pop() || "FILE").toUpperCase(); - const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output"); - - if (!previewFile) { - wrapper.innerHTML = ""; - previewFile = document.createElement("div"); - previewFile.className = "comfy-sidebar-file-wrapper"; - - // Top Row: Right-aligned badge - const topRow = document.createElement("div"); - Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" }); - - const badge = document.createElement("span"); - badge.className = "comfy-sidebar-file-badge"; - badge.textContent = ext; - topRow.appendChild(badge); - - // Center Area: Inline icon + file name - const centerArea = document.createElement("div"); - Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" }); - - const icon = document.createElement("span"); - icon.className = "pi pi-file comfy-sidebar-file-icon"; - - const title = document.createElement("span"); - title.className = "comfy-sidebar-file-title"; - title.textContent = filename; - title.title = filename; - - centerArea.append(icon, title); - - // Bottom Row: Folder location + open action - const bottomRow = document.createElement("div"); - Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }); - - const folderInfo = document.createElement("div"); - folderInfo.className = "comfy-sidebar-file-subtext"; - folderInfo.innerHTML = `${folderName}`; - - const openHint = document.createElement("div"); - openHint.className = "comfy-sidebar-file-subtext"; - openHint.innerHTML = `Open Folder`; - - bottomRow.append(folderInfo, openHint); - - previewFile.append(topRow, centerArea, bottomRow); - wrapper.appendChild(previewFile); - } else { - const title = previewFile.querySelector(".comfy-sidebar-file-title"); - if (title) { title.textContent = filename; title.title = filename; } - const badge = previewFile.querySelector(".comfy-sidebar-file-badge"); - if (badge) badge.textContent = ext; - } - - cardObj.firstImgElement = previewFile; - - previewFile.onclick = (ev) => { - ev.stopPropagation(); - openFileOrFolder(img); - }; - - previewFile.setAttribute("draggable", "true"); - previewFile.ondragstart = (e) => { - e.stopPropagation(); - const mimeType = "application/octet-stream"; - try { - e.dataTransfer.setData("text/uri-list", fullUrl); - e.dataTransfer.setData("text/plain", fullUrl); - e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); - if (state && state.workflow) { - e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); - } - } catch (err) {} - e.dataTransfer.effectAllowed = "copy"; - }; -} - -function renderCardImages(cardObj, state) { - 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; - if (cardObj.dimEl) cardObj.dimEl.style.display = "none"; - return; - } - - const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; - const isVideo = isVideoFormat(src); - const is3D = is3DFormat(src) || is3DFormat(img.filename); - const isAudio = isAudioFormat(src) || isAudioFormat(img.filename); - const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes(".")); - - let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper"); - if (!wrapper) { - cardObj.grid.innerHTML = ""; - wrapper = document.createElement("div"); - wrapper.className = "comfy-sidebar-media-wrapper"; - Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" }); - cardObj.grid.appendChild(wrapper); - } - - let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay"); - if (!ctrlOverlay) { - ctrlOverlay = document.createElement("div"); - ctrlOverlay.className = "comfy-sidebar-ctrl-overlay"; - ctrlOverlay.innerHTML = `Open Location`; - wrapper.appendChild(ctrlOverlay); - } - - if (is3D) { - render3DCardPreview(cardObj, wrapper, src, img, state); - return; - } - - if (isAudio) { - renderAudioCardPreview(cardObj, wrapper, src, img, state); - return; - } - - if (!isImage && !isVideo) { - renderGenericFileCardPreview(cardObj, wrapper, src, img, state); - return; - } - - let mediaEl = wrapper.querySelector("img, video"); - const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video")); - - if (needsRebuild) { - if (mediaEl) mediaEl.remove(); - mediaEl = isVideo ? document.createElement("video") : document.createElement("img"); - Object.assign(mediaEl.style, { - width: "100%", - borderRadius: "2px", - display: "block", - cursor: "zoom-in", - webkitUserDrag: "element", - zIndex: "1" - }); - wrapper.insertBefore(mediaEl, wrapper.firstChild); - } - - cardObj.firstImgElement = mediaEl; - - mediaEl.onclick = (ev) => { - ev.stopPropagation(); - if (ev.ctrlKey || ev.metaKey) { - openFileOrFolder(img); - return; - } - showFullscreenPreview([src], ev.shiftKey); - }; - - mediaEl.onerror = async () => { - if (src && !src.startsWith("blob:") && !is3D && !isAudio) { - try { - const res = await fetch(src, { method: "HEAD" }); - if (res.status === 404) { - deletePromptState(state.pid); - scheduleStateSave(); - if (cardObj.element) cardObj.element.remove(); - return; - } - } catch (e) {} - renderGenericFileCardPreview(cardObj, wrapper, src, img, state); - } - }; - - const applyDimensions = (width, height) => { - if (cardObj.dimEl && width && height) { - cardObj.dimEl.textContent = `${width}x${height}`; - cardObj.dimEl.style.display = "block"; - } - }; - - const isUnfinished = state.status ? (state.status !== "completed") : false; - if (isUnfinished) { - mediaEl.setAttribute("draggable", "false"); - mediaEl.style.cursor = "grab"; - if (mediaEl._currentDragStart) { - mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); - delete mediaEl._currentDragStart; - } - } else { - mediaEl.setAttribute("draggable", "true"); - mediaEl.style.cursor = "zoom-in"; - - if (isVideo) { - const fullSrc = src.startsWith("http") ? src : window.location.origin + src; - const filename = img.filename || "output.mp4"; - const mimeType = src.includes(".webm") ? "video/webm" : "video/mp4"; - - const videoDragHandler = (e) => { - try { - e.dataTransfer.setData("text/uri-list", fullSrc); - e.dataTransfer.setData("text/plain", fullSrc); - e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`); - if (state && state.workflow) { - e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); - } - } catch (err) {} - e.dataTransfer.effectAllowed = "copy"; - e.stopPropagation(); - }; - - if (mediaEl._currentDragStart) { - mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); - } - mediaEl.addEventListener("dragstart", videoDragHandler); - mediaEl._currentDragStart = videoDragHandler; - } else { - if (mediaEl._currentDragStart) { - mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); - delete mediaEl._currentDragStart; - } - } - } - - if (isVideo) { - mediaEl.muted = true; - mediaEl.playsInline = true; - mediaEl.preload = "metadata"; - mediaEl.loop = true; - - mediaEl.onloadedmetadata = () => { - applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); - }; - if (mediaEl.readyState >= 1) { - applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); - } - - let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon"); - if (!playIcon) { - playIcon = document.createElement("div"); - playIcon.className = "comfy-sidebar-play-icon"; - Object.assign(playIcon.style, { - position: "absolute", top: "0", left: "0", width: "100%", height: "100%", - pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease" - }); - playIcon.innerHTML = ` - - - - - `; - wrapper.appendChild(playIcon); - } - - mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); }; - mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); }; - } else { - mediaEl.onload = () => { - applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); - if (state._oldPreviewBlobUrl) { - try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){} - delete state._oldPreviewBlobUrl; - } - }; - if (mediaEl.complete && mediaEl.naturalWidth) { - applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); - } - - const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon"); - if (playIcon) playIcon.remove(); - } - - const currentSrc = mediaEl.getAttribute("src") || mediaEl.src; - if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) { - if (src.startsWith("blob:") && !isVideo) { - const tempImg = new Image(); - tempImg.onload = () => { - const oldBlob = mediaEl._lastBlob; - mediaEl.src = src; - mediaEl._lastBlob = src; - applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight); - if (oldBlob && oldBlob !== src && oldBlob.startsWith("blob:")) { - try { URL.revokeObjectURL(oldBlob); } catch(e){} - } - }; - tempImg.src = src; - } else { - mediaEl.src = isVideo ? src + "#t=0.001" : src; - } - } else { - if (isVideo) { - if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); - } else { - if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); - } - } - - let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar"); - if (state.images.length > 1) { - if (!navBar) { - navBar = document.createElement("div"); - navBar.className = "comfy-sidebar-batch-navbar"; - Object.assign(navBar.style, { - position: "absolute", bottom: "6px", left: "50%", - display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)", - padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px", - fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto", - boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)" - }); - - const prevBtn = document.createElement("span"); - prevBtn.className = "pi pi-chevron-left"; - prevBtn.style.cursor = "pointer"; - prevBtn.onclick = (ev) => { - ev.stopPropagation(); - cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length; - renderCardImages(cardObj, state); - }; - - 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"; - - label.onclick = (ev) => { - ev.stopPropagation(); - if (!State.activeSubmenuBatchImages && !State.activeSubmenuPromptId) { - const scrollEl = getScrollContainer(); - if (scrollEl) State.mainQueueScrollTop = scrollEl.scrollTop; - } - State.activeSubmenuBatchImages = { - pid: state.pid || cardObj.element.id.replace("card-", ""), - images: state.images, - workflow: state.workflow, - nodeOutputs: state.nodeOutputs - }; - resetAllCardHoverStates(); - 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); - }; - - navBar.append(prevBtn, label, nextBtn); - wrapper.appendChild(navBar); - } - - const label = navBar.querySelector(".comfy-sidebar-batch-label"); - if (label) label.textContent = `${idx + 1}/${state.images.length}`; - } else { - if (navBar) navBar.remove(); - } -} - -let globalClickRegistered = false; - -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: 999"], .comfy-sidebar-comparison-overlay'); - - if (!clickedInsideSidebar && !clickedFullscreenOverlay) { - State.activeSubmenuPromptId = null; - State.activeSubmenuBatchImages = null; - resetAllCardHoverStates(); - renderDOM(); - document.removeEventListener("click", handleGlobalClick, true); - globalClickRegistered = false; - } -}; - -let renderTimeout = null; -export function renderDOM() { - if (renderTimeout) cancelAnimationFrame(renderTimeout); - renderTimeout = requestAnimationFrame(() => { - const showPendingSummary = app.ui.settings.getSettingValue("Comfy Sidebar.Show Pending Count Only") ?? 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; - - 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; - resetAllCardHoverStates(); - 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"; - timerEl.style.transform = "translateZ(0)"; - - 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", transform: "translateZ(0)" - }); - - const grid = document.createElement("div"); - grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px"; - - const p = document.createElement("div"); - p.className = "comfy-sidebar-text-clamp"; - - const hoverPanel = document.createElement("div"); - hoverPanel.className = "comfy-sidebar-hover-panel"; - Object.assign(hoverPanel.style, { - position: "absolute", bottom: "4px", right: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnImg = document.createElement("span"); - btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; - btnImg.title = "Download Object"; - - const btnDel = document.createElement("span"); - btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; - btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)"; - const btnDelLabel = document.createElement("span"); - btnDelLabel.className = "comfy-sidebar-del-label"; - btnDelLabel.textContent = "Delete File"; - btnDel.appendChild(btnDelLabel); - - let isDeletePending = false, isDiskDelete = false, deleteTimeout = null; - const resetDelete = () => { - isDeletePending = false; - isDiskDelete = false; - btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); - btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)"; - if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } - }; - - btnDel.onclick = async (ev) => { - ev.stopPropagation(); - const wantsDiskDelete = ev.ctrlKey || ev.metaKey; - - if (!isDeletePending) { - isDeletePending = true; - isDiskDelete = wantsDiskDelete; - btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); - btnDel.title = wantsDiskDelete - ? "Ctrl+Click again to delete file from DISK / TRASH" - : "Click again to confirm removing from batch"; - deleteTimeout = setTimeout(resetDelete, 2000); - } else { - const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; - resetDelete(); - - // 1. Delete only this single file from disk - if (shouldDeleteFromDisk && img && img.filename) { - try { - await fetch("/comfy-sidebar/delete-file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: img.filename, - subfolder: img.subfolder || "", - type: img.type || "output" - }) - }); - } catch (e) {} - } - - // 2. Remove this image from batch list, prompt state, and nodeOutputs - const pid = batchInfo.pid; - const state = promptStates.get(pid); - const imgIdx = batchInfo.images.indexOf(img); - if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1); - - if (state) { - if (Array.isArray(state.images)) { - const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || "")); - if (stIdx > -1) state.images.splice(stIdx, 1); - } - - // Sync nodeOutputs so the Intermediate Outputs submenu stays updated - if (state.nodeOutputs) { - removeImageFromNodeOutputs(state.nodeOutputs, img); - } - - state.rendered = false; - } - - // Invalidate main card's cached index & signature - const mainCardObj = cardElements.get(pid); - if (mainCardObj) { - mainCardObj.currentImageIndex = 0; - mainCardObj.lastImagesSignature = ""; - } - - // If batch is now empty, return to main queue - if (batchInfo.images.length === 0) { - State.activeSubmenuBatchImages = null; - } - - scheduleStateSave(); - renderDOM(); - } - }; - - hoverPanel.append(btnImg, btnDel); - - const leftHoverPanel = document.createElement("div"); - leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; - Object.assign(leftHoverPanel.style, { - position: "absolute", bottom: "4px", left: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnFocus = document.createElement("span"); - btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; - btnFocus.title = "Show Node"; - leftHoverPanel.appendChild(btnFocus); - - card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel); - cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null }; - cardElements.set(cardId, cardObj); - - card.addEventListener("mouseenter", () => { - syncCardButtonVisibility(cardObj, { status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs }); - }); - } - - cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, { pid: batchInfo.pid, status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs }); - 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); - - const scrollEl = getScrollContainer(); - if (scrollEl) scrollEl.scrollTop = 0; - updateScrollTopBtnVisibility(); - return; - } - - if (State.activeSubmenuPromptId) { - const st = promptStates.get(State.activeSubmenuPromptId); - if (!st) { - State.activeSubmenuPromptId = null; - renderDOM(); - return; - } - - if (headerTitle) { - headerTitle.textContent = `Outputs of #${State.activeSubmenuPromptId}`; - headerTitle.style.cursor = "pointer"; - headerTitle.title = "Go Back to Queue"; - headerTitle.onclick = () => { - State.activeSubmenuPromptId = null; - resetAllCardHoverStates(); - renderDOM(); - }; - } - if (headerSearchIcon) headerSearchIcon.style.display = "none"; - if (headerActions) headerActions.style.display = "none"; - - if (!globalClickRegistered) { - document.addEventListener("click", handleGlobalClick, true); - globalClickRegistered = true; - } - - const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow); - const seenFiles = new Set(); - const outputs = []; - - // Deduplicate across nodes so two nodes producing the exact same image don't duplicate cards - rawOutputs.forEach(out => { - const uniqueImgs = (out.images || []).filter(img => { - const key = `${img.subfolder || ""}/${img.filename}`; - if (seenFiles.has(key)) return false; - seenFiles.add(key); - return true; - }); - if (uniqueImgs.length > 0) { - outputs.push({ nodeId: out.nodeId, images: uniqueImgs }); - } - }); - - 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"; - timerEl.style.transform = "translateZ(0)"; - - 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", transform: "translateZ(0)" - }); - - const grid = document.createElement("div"); - grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px"; - - const p = document.createElement("div"); - p.className = "comfy-sidebar-text-clamp"; - - const hoverPanel = document.createElement("div"); - hoverPanel.className = "comfy-sidebar-hover-panel"; - Object.assign(hoverPanel.style, { - position: "absolute", bottom: "4px", right: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnImg = document.createElement("span"); - btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; - btnImg.title = "Download Object"; - - const btnDel = document.createElement("span"); - btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; - btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)"; - const btnDelLabel = document.createElement("span"); - btnDelLabel.className = "comfy-sidebar-del-label"; - btnDelLabel.textContent = "Delete File"; - btnDel.appendChild(btnDelLabel); - - let isDeletePending = false, isDiskDelete = false, deleteTimeout = null; - const resetDelete = () => { - isDeletePending = false; - isDiskDelete = false; - btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); - btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)"; - if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } - }; - - btnDel.onclick = async (ev) => { - ev.stopPropagation(); - const wantsDiskDelete = ev.ctrlKey || ev.metaKey; - - if (!isDeletePending) { - isDeletePending = true; - isDiskDelete = wantsDiskDelete; - btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); - btnDel.title = wantsDiskDelete - ? "Ctrl+Click again to delete this node's files from DISK / TRASH" - : "Click again to confirm removing output"; - deleteTimeout = setTimeout(resetDelete, 2000); - } else { - const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; - resetDelete(); - - // 1. Delete files belonging only to this specific node - if (shouldDeleteFromDisk && out.images) { - for (const imgItem of out.images) { - if (imgItem && imgItem.filename) { - try { - await fetch("/comfy-sidebar/delete-file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: imgItem.filename, - subfolder: imgItem.subfolder || "", - type: imgItem.type || "output" - }) - }); - } catch (e) {} - } - } - } - - // 2. Remove node from state.nodeOutputs - if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) { - delete st.nodeOutputs[out.nodeId]; - } - - // 3. Update primary images if needed - const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow); - if (remainingOutputs.length > 0) { - st.images = remainingOutputs[remainingOutputs.length - 1].images || []; - } else { - st.images = []; - State.activeSubmenuPromptId = null; - } - - scheduleStateSave(); - renderDOM(); - } - }; - - hoverPanel.append(btnImg, btnDel); - - const leftHoverPanel = document.createElement("div"); - leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; - Object.assign(leftHoverPanel.style, { - position: "absolute", bottom: "4px", left: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnFocus = document.createElement("span"); - btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; - btnFocus.title = "Show Node"; - leftHoverPanel.appendChild(btnFocus); - - card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel); - cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null }; - cardElements.set(cardId, cardObj); - - card.addEventListener("mouseenter", () => { - syncCardButtonVisibility(cardObj, { status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs }); - }); - } - - if (out.images && out.images.length > 0) { - cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, { pid: st.pid, status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs }); - } else { - 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); - - updateScrollTopBtnVisibility(); - return; - } - - 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)); - tasksArray.sort((a, b) => b.timestamp - a.timestamp); - - const activeTasks = tasksArray.filter(t => t.status === "active"); - const completedTasks = tasksArray.filter(t => t.status === "completed" || t.status === "cancelled" || t.status === "error"); - const pendingTasks = tasksArray.filter(t => t.status === "pending").sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0)); - - const syncCardElement = (state) => { - let cardObj = cardElements.get(state.pid); - const isFinalStatus = state.status === "completed" || state.status === "cancelled" || state.status === "error"; - if (cardObj && isFinalStatus && state.rendered) { - // Ensure image changes (e.g. deletions from batch submenu) update the preview immediately - const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`; - if (cardObj.lastImagesSignature !== currentImagesSignature) { - cardObj.currentImageIndex = Math.min(cardObj.currentImageIndex || 0, Math.max(0, (state.images?.length || 1) - 1)); - if (!state.images || state.images.length === 0) { - cardObj.grid.innerHTML = ""; - cardObj.firstImgElement = null; - cardObj.placeholder.style.display = "block"; - } else { - cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, state); - } - cardObj.lastImagesSignature = currentImagesSignature; - } - syncCardButtonVisibility(cardObj, state); - return cardObj.element; - } - - 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 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", zIndex: "10" }); - const grid = document.createElement("div"); grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px"; - - const p = document.createElement("div"); - p.className = "comfy-sidebar-text-clamp"; - - 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"); - hoverPanel.className = "comfy-sidebar-hover-panel"; - Object.assign(hoverPanel.style, { - position: "absolute", bottom: "4px", right: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnImg = document.createElement("span"); btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; btnImg.title = "Download Object"; - - const btnCopy = document.createElement("span"); - btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; - btnCopy.title = "Copy Image to Clipboard"; - - const btnJson = document.createElement("span"); btnJson.className = "pi pi-file comfy-sidebar-card-action-btn"; btnJson.title = "Download JSON"; - const btnDel = document.createElement("span"); - btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; - btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)"; - const btnDelLabel = document.createElement("span"); - btnDelLabel.className = "comfy-sidebar-del-label"; - btnDelLabel.textContent = "Delete File"; - btnDel.appendChild(btnDelLabel); - - const leftHoverPanel = document.createElement("div"); - leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; - Object.assign(leftHoverPanel.style, { - position: "absolute", bottom: "4px", left: "4px", - flexDirection: "column", gap: "4px", zIndex: "20" - }); - - const btnFocus = document.createElement("span"); - btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; - btnFocus.title = "Show Node"; - - const leftHoverBtn = document.createElement("span"); - leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn"; - leftHoverBtn.title = "View all intermediate outputs"; - - hoverPanel.append(btnImg, btnJson, btnDel); - leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn); - - card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverPanel); - - cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnCopy, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" }; - - card.addEventListener("mouseenter", () => { - syncCardButtonVisibility(cardObj, state); - }); - - card.addEventListener("dragstart", (e) => { - const isUnfinished = state.status && state.status !== "completed"; - const hasNoImages = !state.images || state.images.length === 0; - - if (state.workflow && (hasNoImages || isUnfinished)) { - if (cardObj.firstImgElement) { - try { e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15); } catch(err){} - } - const jsonStr = JSON.stringify(state.workflow, null, 2); - const filename = `workflow_${state.pid}.json`; - const base64Data = btoa(unescape(encodeURIComponent(jsonStr))); - - try { - e.dataTransfer.setData("DownloadURL", `application/json:${filename}:data:application/json;base64,${base64Data}`); - e.dataTransfer.setData("application/json", jsonStr); - } catch (err) {} - e.dataTransfer.effectAllowed = "copy"; - } - }); - cardObj.element.id = `card-${state.pid}`; - cardElements.set(state.pid, cardObj); - } - - syncCardButtonVisibility(cardObj, state); - - cardObj.element.className = `comfy-sidebar-card ${state.status}`; - - const isUnfinished = state.status && state.status !== "completed"; - if (state.images && state.images.length > 0 && !isUnfinished) { - cardObj.element.removeAttribute("draggable"); - } else { - cardObj.element.setAttribute("draggable", "true"); - } - - if (state.status === "active") { - cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "..."; - cardObj.timerEl.style.display = "block"; - } else if (state.duration !== undefined && state.duration !== null) { - cardObj.timerEl.textContent = state.duration.toFixed(2) + "s"; cardObj.timerEl.style.display = "block"; - } else cardObj.timerEl.style.display = "none"; - - if (state.status === "cancelled") Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" }); - else if (state.status === "error") Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" }); - else cardObj.statusBadge.style.display = "none"; - if (state.status === "cancelled") cardObj.statusBadge.textContent = "Cancelled"; - else if (state.status === "error") cardObj.statusBadge.textContent = "Error"; - - 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 if (state.status === "active") { - cardObj.cancelBtn.style.display = "flex"; - cardObj.cancelBtn.onclick = async (ev) => { - ev.stopPropagation(); - await api.interrupt(); - await syncQueueFn(); - }; - } else { - cardObj.cancelBtn.style.display = "none"; - } - - let deleteTimeout = null, isDeletePending = false, isDiskDelete = false; - const resetDeleteBtn = () => { - isDeletePending = false; - isDiskDelete = false; - cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); - cardObj.btnDel.title = "Delete Card (Ctrl+Click to delete from disk)"; - if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } - }; - - cardObj.btnDel.onclick = async (ev) => { - ev.stopPropagation(); - const wantsDiskDelete = ev.ctrlKey || ev.metaKey; - - if (!isDeletePending) { - isDeletePending = true; - isDiskDelete = wantsDiskDelete; - cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); - cardObj.btnDel.title = wantsDiskDelete - ? "Ctrl+Click again to delete file from DISK / TRASH" - : "Click again to confirm removing card"; - deleteTimeout = setTimeout(resetDeleteBtn, 2000); - } else { - const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; - resetDeleteBtn(); - - // Delete all files from disk across ALL nodes in this run - if (shouldDeleteFromDisk) { - const filesToDelete = []; - const seen = new Set(); - - const addFile = (item) => { - if (item && item.filename) { - const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`; - if (!seen.has(key)) { - seen.add(key); - filesToDelete.push(item); - } - } - }; - - // 1. Primary card images/files - (state.images || []).forEach(addFile); - - // 2. All intermediate node outputs - const allOutputs = getRunOutputs(state.nodeOutputs, state.workflow); - allOutputs.forEach(out => (out.images || []).forEach(addFile)); - - // 3. Delete each file from disk / trash - for (const fileItem of filesToDelete) { - try { - await fetch("/comfy-sidebar/delete-file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: fileItem.filename, - subfolder: fileItem.subfolder || "", - type: fileItem.type || "output" - }) - }); - } catch (e) {} - } - } - - promptStates.delete(state.pid); - await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); - scheduleStateSave(); - renderDOM(); - } - }; - - const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`; - if (cardObj.lastImagesSignature !== currentImagesSignature) { - if (!state.images || state.images.length === 0) { - cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null; cardObj.placeholder.style.display = "block"; - } else { - cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, state); - } - cardObj.lastImagesSignature = currentImagesSignature; - } - - if (state.images.length === 0) { - if (state.texts && state.texts.length > 0) { - const fullText = state.texts.join("\n\n"); - cardObj.placeholder.textContent = fullText; - cardObj.placeholder.title = "Click to read full text"; - cardObj.placeholder.className = "comfy-sidebar-text-clamp"; - cardObj.placeholder.style.cssText = ""; - cardObj.placeholder.style.display = "-webkit-box"; - cardObj.placeholder.onclick = (e) => { - e.stopPropagation(); - stopAllAudioPlayback(); - showFullscreenPreview([{ text: fullText, pid: state.pid }]); - }; - } else { - cardObj.placeholder.textContent = state.progressText || "No Outputs"; - cardObj.placeholder.className = ""; - Object.assign(cardObj.placeholder.style, { - fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", - userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default" - }); - cardObj.placeholder.onclick = null; - } - } - - if (state.status === "active") { - if (showWorkingNode) { - cardObj.statusText.style.display = "block"; - cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Processing...${state.progress ? ` ${state.progress}%` : ""}`; - } else cardObj.statusText.style.display = "none"; - cardObj.progressContainer.style.display = "block"; cardObj.progressBar.style.width = `${state.progress || 0}%`; - } else { cardObj.statusText.style.display = "none"; cardObj.progressContainer.style.display = "none"; } - - if (isFinalStatus) state.rendered = true; - return cardObj.element; - }; - - for (const [pid] of cardElements.entries()) { - if (pid !== "pending-summary-card" && pid !== "pending-cancel-all-standalone" && !promptStates.has(pid)) cardElements.delete(pid); - } - - const pendingCount = Array.from(promptStates.values()).filter(t => t.status === "pending").length; - const targetElements = []; - - if (pendingCount > 0) { - if (showPendingSummary) { - let pCard = cardElements.get("pending-summary-card"); - if (!pCard) { - const el = document.createElement("div"); Object.assign(el.style, { background: "#181818", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px", marginBottom: "12px", textAlign: "center", fontSize: "12px", fontWeight: "bold", color: "#aaa", breakInside: "avoid", display: "flex", flexDirection: "column", gap: "8px" }); - const textDiv = document.createElement("div"); el.appendChild(textDiv); - const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel All Pending"; Object.assign(cancelBtn.style, { background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px", cursor: "pointer", fontSize: "11px", fontWeight: "bold" }); - cancelBtn.onclick = async () => { await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) }); await syncQueueFn(); }; - el.appendChild(cancelBtn); pCard = { element: el, textDiv }; cardElements.set("pending-summary-card", pCard); - } - pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`; - targetElements.push(pCard.element); - } else { - let btnCard = cardElements.get("pending-cancel-all-standalone"); - if (!btnCard) { - const btn = document.createElement("button"); btn.textContent = "Cancel All Pending"; Object.assign(btn.style, { background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px", cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%", marginBottom: "12px", breakInside: "avoid" }); - btn.onclick = async () => { await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) }); await syncQueueFn(); }; - btnCard = { element: btn }; cardElements.set("pending-cancel-all-standalone", btnCard); - } - targetElements.push(btnCard.element); - pendingTasks.forEach(st => targetElements.push(syncCardElement(st))); - } - } - activeTasks.forEach(st => targetElements.push(syncCardElement(st))); - completedTasks.forEach(st => targetElements.push(syncCardElement(st))); - - 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); - - if (State.mainQueueScrollTop !== null) { - const restorePos = State.mainQueueScrollTop; - State.mainQueueScrollTop = null; - - const scrollEl = getScrollContainer(); - if (scrollEl) { - scrollEl.scrollTop = restorePos; - requestAnimationFrame(() => { - scrollEl.scrollTop = restorePos; - }); - } - } - - updateScrollTopBtnVisibility(); - }); -} \ No newline at end of file diff --git a/js/ui/audioController.js b/js/ui/audioController.js new file mode 100644 index 0000000..1779a50 --- /dev/null +++ b/js/ui/audioController.js @@ -0,0 +1,34 @@ +let currentlyPlayingAudio = null; +let activeComparisonViewer = null; + +export function stopAllAudioPlayback() { + if (currentlyPlayingAudio) { + currentlyPlayingAudio.pause(); + currentlyPlayingAudio.currentTime = 0; + if (typeof currentlyPlayingAudio._onResetUI === "function") { + currentlyPlayingAudio._onResetUI(); + } + currentlyPlayingAudio = null; + } +} + +export function setCurrentlyPlayingAudio(audio) { + stopAllAudioPlayback(); + currentlyPlayingAudio = audio; +} + +export function getCurrentlyPlayingAudio() { + return currentlyPlayingAudio; +} + +export function setActiveComparisonViewer(viewer) { + activeComparisonViewer = viewer; +} + +export function getActiveComparisonViewer() { + return activeComparisonViewer; +} + +export function isAudioViewerOpen() { + return !!(activeComparisonViewer && activeComparisonViewer.isAudio); +} diff --git a/js/ui/cardRenderer.js b/js/ui/cardRenderer.js new file mode 100644 index 0000000..83250a1 --- /dev/null +++ b/js/ui/cardRenderer.js @@ -0,0 +1,519 @@ +import { app } from "/scripts/app.js"; +import { PromptStatus } from "../core/constants.js"; +import { store } from "../core/store.js"; +import { renderCardImages } from "./mediaPreview.js"; +import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage } from "./mediaActions.js"; +import { centerAndSelectCanvasNode } from "../comfy/adapter.js"; +import { getRunOutputs, extractWorkflowFromPng, isImageFormat } from "../utils/utils.js"; +import { showFullscreenPreview } from "../utils/comparison.js"; +import { stopAllAudioPlayback } from "./audioController.js"; +import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js"; + +export const cardPool = new Map(); + +export function resetAllCardHoverStates() { + for (const cardObj of cardPool.values()) { + if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none"; + if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none"; + } +} + +export function syncCardButtonVisibility(cardObj, state) { + if (!cardObj) return; + + const isPendingOrActive = state.status === PromptStatus.PENDING || state.status === PromptStatus.ACTIVE; + if (isPendingOrActive) { + if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important"); + if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important"); + return; + } else { + if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display"); + if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display"); + } + + const isCompleted = state.status === PromptStatus.COMPLETED; + const hasRealDiskFiles = isCompleted && state.images && state.images.length > 0 && + !state.images.every(img => img.isFallback || (img.url && img.url.startsWith("blob:"))); + + const hasRealImages = hasRealDiskFiles && !state.images.some(img => img.url && img.url.startsWith("blob:")); + const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null; + const isPng = currentImg && (currentImg.filename || "").toLowerCase().endsWith(".png"); + + if (cardObj.btnImg) { + if (hasRealImages) { + cardObj.btnImg.style.removeProperty("display"); + 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 || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + a.download = img.filename || "output"; + a.click(); + }); + }; + } else { + cardObj.btnImg.style.setProperty("display", "none", "important"); + } + } + + if (cardObj.btnCopy) { + const isCopyableImage = hasRealImages && currentImg && isImageFormat(currentImg.filename || currentImg.url); + if (isCopyableImage) { + cardObj.btnCopy.style.removeProperty("display"); + cardObj.btnCopy.style.display = "inline-flex"; + cardObj.btnCopy.onclick = async (ev) => { + ev.stopPropagation(); + const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`; + const success = await copyImageToClipboard(src); + if (success) { + cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn"; + cardObj.btnCopy.style.color = "#4ade80"; + setTimeout(() => { + cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + cardObj.btnCopy.style.color = ""; + }, 1500); + } + }; + } else { + cardObj.btnCopy.style.setProperty("display", "none", "important"); + } + } + + if (cardObj.btnJson) { + if (state.workflow || isPng) { + cardObj.btnJson.style.removeProperty("display"); + cardObj.btnJson.style.display = "inline-flex"; + cardObj.btnJson.onclick = async (ev) => { + ev.stopPropagation(); + let wf = state.workflow; + if (!wf && currentImg) { + const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`; + wf = await extractWorkflowFromPng(src); + if (wf) state.workflow = wf; + } + if (!wf) { + alert("No workflow metadata found in this file."); + return; + } + const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `workflow_${state.pid || "comfy"}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + }; + } else { + cardObj.btnJson.style.setProperty("display", "none", "important"); + } + } + + if (cardObj.btnDel) { + // Only show delete button if we have actual verified output files on disk to delete + if (hasRealDiskFiles) { + cardObj.btnDel.style.removeProperty("display"); + cardObj.btnDel.style.display = "inline-flex"; + cardObj.canDeleteFromDisk = true; + if (cardObj.btnDelLabel) { + cardObj.btnDelLabel.style.display = ""; + } + cardObj.btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)"; + } else { + cardObj.btnDel.style.setProperty("display", "none", "important"); + cardObj.canDeleteFromDisk = false; + } + } + + if (cardObj.btnFocus) { + const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null; + if (nodeId) { + cardObj.btnFocus.style.removeProperty("display"); + cardObj.btnFocus.style.display = "inline-flex"; + cardObj.btnFocus.onclick = (ev) => { + ev.stopPropagation(); + centerAndSelectCanvasNode(nodeId); + }; + } else { + cardObj.btnFocus.style.setProperty("display", "none", "important"); + } + } + + if (cardObj.leftHoverBtn) { + const outputs = getRunOutputs(state.nodeOutputs, state.workflow); + const validOutputs = outputs.filter(o => o.images && 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 hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0; + + if (hasIntermediates) { + cardObj.leftHoverBtn.style.removeProperty("display"); + cardObj.leftHoverBtn.style.display = "inline-flex"; + cardObj.leftHoverBtn.onclick = (ev) => { + ev.stopPropagation(); + const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') + || store.ui.cardStack?.parentElement + || store.ui.cardStack; + if (scrollEl) { + store.ui.mainQueueScrollTop = scrollEl.scrollTop; + } + store.openOutputsSubmenu(state.pid); + }; + } else { + cardObj.leftHoverBtn.style.setProperty("display", "none", "important"); + } + } +} + +export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true) { + if (!cardObj) return; + if (cardObj.progressBar) { + cardObj.progressBar.style.width = `${progress}%`; + } + if (cardObj.statusText && showWorkingNode) { + cardObj.statusText.style.display = "block"; + const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? "Finishing..." : `[${activeNodeName}]`) : "Processing..."; + cardObj.statusText.textContent = `${nodeText} ${progress}%`; + } +} + +export function getOrCreateCard(state, callbacks = {}) { + let cardObj = cardPool.get(state.pid); + if (cardObj) return cardObj; + + const card = document.createElement("div"); + card.id = `card-${state.pid}`; + card.className = `comfy-sidebar-card ${state.status || "completed"}`; + card.style.position = "relative"; + + const timerEl = document.createElement("div"); + timerEl.className = "comfy-sidebar-card-timer"; + + 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", zIndex: "10" + }); + + 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", transform: "translateZ(0)" + }); + + const grid = document.createElement("div"); + grid.style.display = "flex"; + grid.style.flexDirection = "column"; + grid.style.gap = "6px"; + + const p = document.createElement("div"); + p.className = "comfy-sidebar-text-clamp"; + + 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"); + hoverPanel.className = "comfy-sidebar-hover-panel"; + Object.assign(hoverPanel.style, { + position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnImg = document.createElement("span"); + btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; + btnImg.title = "Download Object"; + + const btnCopy = document.createElement("span"); + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.title = "Copy to Clipboard"; + btnCopy.style.display = "none"; + + const btnJson = document.createElement("span"); + btnJson.className = "pi pi-file comfy-sidebar-card-action-btn"; + btnJson.title = "Download JSON"; + + const btnDel = document.createElement("span"); + btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; + btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)"; + const btnDelLabel = document.createElement("span"); + btnDelLabel.className = "comfy-sidebar-del-label"; + btnDelLabel.textContent = "Delete File"; + btnDel.appendChild(btnDelLabel); + + const leftHoverPanel = document.createElement("div"); + leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; + Object.assign(leftHoverPanel.style, { + position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnFocus = document.createElement("span"); + btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; + btnFocus.title = "Show Node"; + + const leftHoverBtn = document.createElement("span"); + leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn"; + leftHoverBtn.title = "View all intermediate outputs"; + + hoverPanel.append(btnImg, btnJson, btnDel); + leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn); + + card.append(timerEl, cancelX, sBadge, dimEl, grid, p, pt, statusText, hoverPanel, leftHoverPanel); + + cardObj = { + element: card, + timerEl, + statusBadge: sBadge, + dimEl, + grid, + placeholder: p, + progressContainer: pt, + progressBar: pb, + cancelBtn: cancelX, + hoverPanel, + leftHoverPanel, + btnFocus, + leftHoverBtn, + btnImg, + btnCopy, + btnJson, + btnDel, + btnDelLabel, + canDeleteFromDisk: true, + statusText, + firstImgElement: null, + lastImagesSignature: "", + currentImageIndex: 0 + }; + + 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); + } + }); + + card.addEventListener("dragstart", (e) => { + const liveState = store.getPrompt(state.pid) || state; + const isUnfinished = liveState.status && liveState.status !== PromptStatus.COMPLETED; + const hasNoImages = !liveState.images || liveState.images.length === 0; + + if (liveState.workflow && (hasNoImages || isUnfinished)) { + if (cardObj.firstImgElement) { + try { e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15); } catch(err){} + } + const jsonStr = JSON.stringify(liveState.workflow, null, 2); + const filename = `workflow_${liveState.pid}.json`; + const base64Data = btoa(unescape(encodeURIComponent(jsonStr))); + + try { + e.dataTransfer.setData("DownloadURL", `application/json:${filename}:data:application/json;base64,${base64Data}`); + e.dataTransfer.setData("application/json", jsonStr); + } catch (err) {} + e.dataTransfer.effectAllowed = "copy"; + } + }); + + let deleteTimeout = null, isDeletePending = false, isDiskDelete = false; + const resetDeleteBtn = () => { + isDeletePending = false; + isDiskDelete = false; + cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); + cardObj.btnDel.title = "Delete Card (Ctrl+Click to delete from disk)"; + if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } + }; + + btnDel.onclick = async (ev) => { + ev.stopPropagation(); + const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false; + + if (!isDeletePending) { + isDeletePending = true; + isDiskDelete = wantsDiskDelete; + cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); + cardObj.btnDel.title = wantsDiskDelete + ? "Ctrl+Click again to delete file from DISK / TRASH" + : "Click again to confirm removing card"; + deleteTimeout = setTimeout(resetDeleteBtn, 2000); + } else { + const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; + resetDeleteBtn(); + + const liveState = store.getPrompt(state.pid) || state; + + if (shouldDeleteFromDisk) { + const filesToDelete = []; + const seen = new Set(); + const addFile = (item) => { + if (item && item.filename) { + const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`; + if (!seen.has(key)) { + seen.add(key); + filesToDelete.push(item); + } + } + }; + + (liveState.images || []).forEach(addFile); + const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow); + allOutputs.forEach(out => (out.images || []).forEach(addFile)); + + for (const fileItem of filesToDelete) { + await deleteFileOnServer(fileItem); + } + } + + deleteHistoryItem(state.pid); + } + }; + + cardPool.set(state.pid, cardObj); + return cardObj; +} + +export function updateCardDOM(cardObj, state, showPendingSummary = true, showWorkingNode = true) { + cardObj.element.className = `comfy-sidebar-card ${state.status}`; + cardObj.element.style.position = "relative"; + + const isUnfinished = state.status && state.status !== PromptStatus.COMPLETED; + if (state.images && state.images.length > 0 && !isUnfinished) { + cardObj.element.removeAttribute("draggable"); + } else { + cardObj.element.setAttribute("draggable", "true"); + } + + if (state.status === PromptStatus.ACTIVE) { + cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "..."; + cardObj.timerEl.style.display = "block"; + } else if (state.duration !== undefined && state.duration !== null) { + cardObj.timerEl.textContent = state.duration.toFixed(2) + "s"; + cardObj.timerEl.style.display = "block"; + } else { + cardObj.timerEl.style.display = "none"; + } + + if (state.status === PromptStatus.CANCELLED) { + Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" }); + cardObj.statusBadge.textContent = "Cancelled"; + } else if (state.status === PromptStatus.ERROR) { + Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" }); + cardObj.statusBadge.textContent = "Error"; + } else { + cardObj.statusBadge.style.display = "none"; + } + + if (state.status === PromptStatus.PENDING && !showPendingSummary) { + cardObj.cancelBtn.style.display = "flex"; + cardObj.cancelBtn.onclick = async (ev) => { + ev.stopPropagation(); + cancelPendingTask(state.pid); + }; + } else if (state.status === PromptStatus.ACTIVE) { + cardObj.cancelBtn.style.display = "flex"; + cardObj.cancelBtn.onclick = async (ev) => { + ev.stopPropagation(); + interruptActive(); + }; + } else { + cardObj.cancelBtn.style.display = "none"; + } + + const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`; + if (cardObj.lastImagesSignature !== currentImagesSignature) { + if (!state.images || state.images.length === 0) { + cardObj.grid.innerHTML = ""; + cardObj.firstImgElement = null; + cardObj.placeholder.style.display = "block"; + } else { + cardObj.placeholder.style.display = "none"; + renderCardImages(cardObj, state, (st) => { + const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') + || store.ui.cardStack?.parentElement + || store.ui.cardStack; + if (scrollEl) { + store.ui.mainQueueScrollTop = scrollEl.scrollTop; + } + store.openBatchSubmenu({ + pid: st.pid, + images: st.images, + workflow: st.workflow, + nodeOutputs: st.nodeOutputs + }); + }); + } + cardObj.lastImagesSignature = currentImagesSignature; + } + + if (state.images.length === 0) { + if (state.texts && state.texts.length > 0) { + const fullText = state.texts.join("\n\n"); + cardObj.placeholder.textContent = fullText; + cardObj.placeholder.title = "Click to read full text"; + cardObj.placeholder.className = "comfy-sidebar-text-clamp"; + cardObj.placeholder.style.cssText = ""; + cardObj.placeholder.style.display = "-webkit-box"; + cardObj.placeholder.onclick = (e) => { + e.stopPropagation(); + stopAllAudioPlayback(); + showFullscreenPreview([{ text: fullText, pid: state.pid }]); + }; + } else { + cardObj.placeholder.textContent = state.progressText || "No Outputs"; + cardObj.placeholder.className = ""; + Object.assign(cardObj.placeholder.style, { + fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", + userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default" + }); + cardObj.placeholder.onclick = null; + } + } + + if (state.status === PromptStatus.ACTIVE) { + if (showWorkingNode) { + cardObj.statusText.style.display = "block"; + const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]`) : "Processing..."; + cardObj.statusText.textContent = `${nodeText}${state.progress ? ` ${state.progress}%` : ""}`; + } else { + cardObj.statusText.style.display = "none"; + } + cardObj.progressContainer.style.display = "block"; + cardObj.progressBar.style.width = `${state.progress || 0}%`; + } else { + cardObj.statusText.style.display = "none"; + cardObj.progressContainer.style.display = "none"; + } + + syncCardButtonVisibility(cardObj, state); + return cardObj.element; +} \ No newline at end of file diff --git a/js/ui/mediaActions.js b/js/ui/mediaActions.js new file mode 100644 index 0000000..1a27197 --- /dev/null +++ b/js/ui/mediaActions.js @@ -0,0 +1,119 @@ +export async function copyImageToClipboard(src) { + try { + const res = await fetch(src); + let blob = await res.blob(); + if (blob.type !== "image/png") { + const img = new Image(); + img.crossOrigin = "anonymous"; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = src; + }); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + canvas.getContext("2d").drawImage(img, 0, 0); + blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); + } + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + return true; + } catch (err) { + console.error("Comfy Sidebar: Failed to copy image to clipboard", err); + return false; + } +} + +export async function openFileOrFolder(img) { + if (!img) return; + + try { + const res = await fetch("/comfy-sidebar/open-folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: img.filename, + subfolder: img.subfolder || "", + type: img.type || "output" + }) + }); + if (res.ok) return; + } catch (e) {} + + const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + const a = document.createElement("a"); + a.href = src; + a.target = "_blank"; + a.download = img.filename || "file"; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +export async function deleteFileOnServer(fileItem) { + if (!fileItem || !fileItem.filename) return false; + try { + const res = await fetch("/comfy-sidebar/delete-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: fileItem.filename, + subfolder: fileItem.subfolder || "", + type: fileItem.type || "output" + }) + }); + return res.ok; + } catch (e) { + console.error("Comfy Sidebar: Failed to delete file on server", e); + return false; + } +} + +export function removeImageFromNodeOutputs(nodeOutputs, targetImg) { + if (!nodeOutputs || !targetImg || !targetImg.filename) return; + + const scanAndRemove = (obj) => { + if (!obj || typeof obj !== "object") return; + for (const key in obj) { + const val = obj[key]; + if (Array.isArray(val)) { + for (let i = val.length - 1; i >= 0; i--) { + const item = val[i]; + if (item && typeof item === "object") { + if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) { + val.splice(i, 1); + } else { + scanAndRemove(item); + } + } else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) { + val.splice(i, 1); + } + } + } else if (typeof val === "object") { + scanAndRemove(val); + } + } + }; + + for (const nodeId in nodeOutputs) { + scanAndRemove(nodeOutputs[nodeId]); + } +} + +export function findNodeIdForImage(state, img) { + if (!state || !state.nodeOutputs || !img) return null; + for (const nodeId in state.nodeOutputs) { + const out = state.nodeOutputs[nodeId]; + for (const key in out) { + const val = out[key]; + if (Array.isArray(val)) { + if (val.some(i => i && typeof i === "object" && i.filename === img.filename)) { + return nodeId; + } + } else if (val && typeof val === "object" && val.filename === img.filename) { + return nodeId; + } + } + } + return null; +} diff --git a/js/ui/mediaPreview.js b/js/ui/mediaPreview.js new file mode 100644 index 0000000..aaf9a7b --- /dev/null +++ b/js/ui/mediaPreview.js @@ -0,0 +1,623 @@ +import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js"; +import { openFileOrFolder } from "./mediaActions.js"; +import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js"; +import { showFullscreenPreview } from "../utils/comparison.js"; +import { store } from "../core/store.js"; + +export function render3DCardPreview(cardObj, wrapper, src, img, state) { + let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper"); + const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + + const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || []; + const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null; + const bgImgSrc = lastImageAsset + ? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`) + : null; + + if (!preview3D) { + wrapper.innerHTML = ""; + preview3D = document.createElement("div"); + preview3D.className = "comfy-sidebar-3d-wrapper"; + + if (bgImgSrc) { + const bgImg = document.createElement("img"); + bgImg.className = "comfy-sidebar-3d-preview-img"; + bgImg.src = bgImgSrc; + bgImg.alt = "3D Thumbnail Preview"; + preview3D.appendChild(bgImg); + } + + const overlay = document.createElement("div"); + overlay.className = "comfy-sidebar-3d-overlay"; + + const badge = document.createElement("span"); + badge.className = "comfy-sidebar-3d-badge"; + const ext = (img.filename || src).split('.').pop().toUpperCase(); + badge.textContent = ext || "3D"; + + const icon = document.createElement("span"); + icon.className = "pi pi-box comfy-sidebar-3d-icon"; + + const title = document.createElement("span"); + title.className = "comfy-sidebar-3d-title"; + title.textContent = img.filename || "3D Model"; + + overlay.append(badge, icon, title); + preview3D.appendChild(overlay); + wrapper.appendChild(preview3D); + } else { + const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img"); + if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) { + bgImg.src = bgImgSrc; + } else if (!bgImg && bgImgSrc) { + const newBgImg = document.createElement("img"); + newBgImg.className = "comfy-sidebar-3d-preview-img"; + newBgImg.src = bgImgSrc; + newBgImg.alt = "3D Thumbnail Preview"; + preview3D.insertBefore(newBgImg, preview3D.firstChild); + } + const title = preview3D.querySelector(".comfy-sidebar-3d-title"); + if (title) title.textContent = img.filename || "3D Model"; + } + + cardObj.firstImgElement = preview3D; + + preview3D.onclick = (ev) => { + ev.stopPropagation(); + if (ev.ctrlKey || ev.metaKey) { + openFileOrFolder(img); + return; + } + showFullscreenPreview([fullUrl], ev.shiftKey); + }; + + preview3D.setAttribute("draggable", "true"); + preview3D.ondragstart = (e) => { + e.stopPropagation(); + const filename = img.filename || "model.glb"; + const mimeType = "application/octet-stream"; + + try { + e.dataTransfer.setData("text/uri-list", fullUrl); + e.dataTransfer.setData("text/plain", fullUrl); + e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); + if (state && state.workflow) { + e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); + } + } catch (err) {} + e.dataTransfer.effectAllowed = "copy"; + }; +} + +export function renderAudioCardPreview(cardObj, wrapper, src, img, state) { + let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper"); + const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + const filename = img.filename || getFilenameFromUrl(src) || "audio.wav"; + const ext = filename.split('.').pop().toUpperCase(); + + const playIconSvg = ``; + const stopIconSvg = ``; + + if (!previewAudio) { + wrapper.innerHTML = ""; + previewAudio = document.createElement("div"); + previewAudio.className = "comfy-sidebar-audio-wrapper"; + + const topRow = document.createElement("div"); + Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" }); + + const title = document.createElement("span"); + title.className = "comfy-sidebar-audio-title"; + title.textContent = filename; + + const badge = document.createElement("span"); + badge.className = "comfy-sidebar-audio-badge"; + badge.textContent = ext || "AUDIO"; + + topRow.append(title, badge); + + const centerArea = document.createElement("div"); + Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" }); + + const soundwave = document.createElement("div"); + soundwave.className = "comfy-sidebar-soundwave-container"; + soundwave.style.width = "100%"; + const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12]; + barHeights.forEach((h, i) => { + const bar = document.createElement("div"); + bar.className = "comfy-sidebar-soundwave-bar"; + bar.style.height = `${h}px`; + bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`; + soundwave.appendChild(bar); + }); + + const playBtn = document.createElement("button"); + Object.assign(playBtn.style, { + position: "absolute", width: "36px", height: "36px", borderRadius: "50%", + background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)", + color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center", + justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5", + transition: "all 0.15s ease", outline: "none" + }); + playBtn.innerHTML = playIconSvg; + + centerArea.append(soundwave, playBtn); + + const bottomRow = document.createElement("div"); + Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" }); + + const scrubber = document.createElement("div"); + Object.assign(scrubber.style, { + width: "100%", height: "4px", background: "#334155", borderRadius: "2px", + position: "relative", cursor: "pointer" + }); + const scrubberFill = document.createElement("div"); + Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" }); + scrubber.appendChild(scrubberFill); + + const timeLabel = document.createElement("div"); + Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" }); + timeLabel.textContent = "0:00 / 0:00"; + + bottomRow.append(scrubber, timeLabel); + + const audioEl = document.createElement("audio"); + audioEl.src = fullUrl; + audioEl.preload = "metadata"; + + const formatTime = (t) => { + const m = Math.floor(t / 60); + const s = Math.floor(t % 60); + return `${m}:${s < 10 ? '0' : ''}${s}`; + }; + + const resetAudioUI = () => { + playBtn.innerHTML = playIconSvg; + previewAudio.classList.remove("playing"); + scrubberFill.style.width = "0%"; + timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; + }; + + audioEl._onResetUI = resetAudioUI; + + audioEl.onloadedmetadata = () => { + timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; + }; + + audioEl.ontimeupdate = () => { + if (audioEl.duration > 0) { + const percent = (audioEl.currentTime / audioEl.duration) * 100; + scrubberFill.style.width = `${percent}%`; + timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`; + } + }; + + audioEl.onended = () => { + resetAudioUI(); + if (getCurrentlyPlayingAudio() === audioEl) stopAllAudioPlayback(); + }; + + const togglePlay = (e) => { + e.stopPropagation(); + + if (isAudioViewerOpen()) { + stopAllAudioPlayback(); + showFullscreenPreview([fullUrl]); + return; + } + + if (audioEl.paused) { + setCurrentlyPlayingAudio(audioEl); + audioEl.play().catch(()=>{}); + playBtn.innerHTML = stopIconSvg; + previewAudio.classList.add("playing"); + } else { + audioEl.pause(); + audioEl.currentTime = 0; + resetAudioUI(); + stopAllAudioPlayback(); + } + }; + + playBtn.onclick = togglePlay; + + playBtn.onmouseenter = () => { + playBtn.style.background = "#9333ea"; + playBtn.style.borderColor = "#c084fc"; + playBtn.style.transform = "scale(1.08)"; + }; + playBtn.onmouseleave = () => { + playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)"; + playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)"; + playBtn.style.transform = "scale(1.0)"; + }; + + scrubber.onclick = (e) => { + e.stopPropagation(); + const rect = scrubber.getBoundingClientRect(); + const pos = (e.clientX - rect.left) / rect.width; + if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration; + }; + + previewAudio.append(topRow, centerArea, bottomRow, audioEl); + wrapper.appendChild(previewAudio); + + previewAudio.onclick = (e) => { + if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return; + stopAllAudioPlayback(); + if (e.ctrlKey || e.metaKey) { + openFileOrFolder(img); + return; + } + showFullscreenPreview([fullUrl]); + }; + } + + cardObj.firstImgElement = previewAudio; + + previewAudio.setAttribute("draggable", "true"); + previewAudio.ondragstart = (e) => { + e.stopPropagation(); + const mimeType = "audio/wav"; + try { + e.dataTransfer.setData("text/uri-list", fullUrl); + e.dataTransfer.setData("text/plain", fullUrl); + e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); + if (state && state.workflow) { + e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); + } + } catch (err) {} + e.dataTransfer.effectAllowed = "copy"; + }; +} + +export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) { + let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper"); + const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + const filename = img.filename || getFilenameFromUrl(src) || "output_file"; + const ext = (filename.split('.').pop() || "FILE").toUpperCase(); + const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output"); + + if (!previewFile) { + wrapper.innerHTML = ""; + previewFile = document.createElement("div"); + previewFile.className = "comfy-sidebar-file-wrapper"; + + const topRow = document.createElement("div"); + Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" }); + + const badge = document.createElement("span"); + badge.className = "comfy-sidebar-file-badge"; + badge.textContent = ext; + topRow.appendChild(badge); + + const centerArea = document.createElement("div"); + Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" }); + + const icon = document.createElement("span"); + icon.className = "pi pi-file comfy-sidebar-file-icon"; + + const title = document.createElement("span"); + title.className = "comfy-sidebar-file-title"; + title.textContent = filename; + title.title = filename; + + centerArea.append(icon, title); + + const bottomRow = document.createElement("div"); + Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }); + + const folderInfo = document.createElement("div"); + folderInfo.className = "comfy-sidebar-file-subtext"; + folderInfo.innerHTML = `${folderName}`; + + const openHint = document.createElement("div"); + openHint.className = "comfy-sidebar-file-subtext"; + openHint.innerHTML = `Open Folder`; + + bottomRow.append(folderInfo, openHint); + + previewFile.append(topRow, centerArea, bottomRow); + wrapper.appendChild(previewFile); + } else { + const title = previewFile.querySelector(".comfy-sidebar-file-title"); + if (title) { title.textContent = filename; title.title = filename; } + const badge = previewFile.querySelector(".comfy-sidebar-file-badge"); + if (badge) badge.textContent = ext; + } + + cardObj.firstImgElement = previewFile; + + previewFile.onclick = (ev) => { + ev.stopPropagation(); + openFileOrFolder(img); + }; + + previewFile.setAttribute("draggable", "true"); + previewFile.ondragstart = (e) => { + e.stopPropagation(); + const mimeType = "application/octet-stream"; + try { + e.dataTransfer.setData("text/uri-list", fullUrl); + e.dataTransfer.setData("text/plain", fullUrl); + e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`); + if (state && state.workflow) { + e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); + } + } catch (err) {} + e.dataTransfer.effectAllowed = "copy"; + }; +} + +export function renderCardImages(cardObj, state, onNavigateBatch) { + 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; + if (cardObj.dimEl) cardObj.dimEl.style.display = "none"; + return; + } + + const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + const isVideo = isVideoFormat(src) || isVideoFormat(img.filename); + const is3D = is3DFormat(src) || is3DFormat(img.filename); + const isAudio = isAudioFormat(src) || isAudioFormat(img.filename); + const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes(".")); + + let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper"); + if (!wrapper) { + cardObj.grid.innerHTML = ""; + wrapper = document.createElement("div"); + wrapper.className = "comfy-sidebar-media-wrapper"; + Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" }); + cardObj.grid.appendChild(wrapper); + } + + let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay"); + if (!ctrlOverlay) { + ctrlOverlay = document.createElement("div"); + ctrlOverlay.className = "comfy-sidebar-ctrl-overlay"; + ctrlOverlay.innerHTML = `Open Location`; + wrapper.appendChild(ctrlOverlay); + } + + if (is3D) { + render3DCardPreview(cardObj, wrapper, src, img, state); + return; + } + + if (isAudio) { + renderAudioCardPreview(cardObj, wrapper, src, img, state); + return; + } + + if (!isImage && !isVideo) { + renderGenericFileCardPreview(cardObj, wrapper, src, img, state); + return; + } + + let mediaEl = wrapper.querySelector("img, video"); + const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video")); + + if (needsRebuild) { + if (mediaEl) mediaEl.remove(); + mediaEl = isVideo ? document.createElement("video") : document.createElement("img"); + Object.assign(mediaEl.style, { + width: "100%", + borderRadius: "2px", + display: "block", + cursor: "zoom-in", + webkitUserDrag: "element", + zIndex: "1" + }); + wrapper.insertBefore(mediaEl, wrapper.firstChild); + } + + cardObj.firstImgElement = mediaEl; + + mediaEl.onclick = (ev) => { + ev.stopPropagation(); + if (ev.ctrlKey || ev.metaKey) { + openFileOrFolder(img); + return; + } + showFullscreenPreview([src], ev.shiftKey); + }; + + mediaEl.onerror = async () => { + if (src && !src.startsWith("blob:") && !is3D && !isAudio) { + try { + const res = await fetch(src, { method: "HEAD" }); + if (res.status === 404) { + store.deletePrompt(state.pid); + if (cardObj.element) cardObj.element.remove(); + return; + } + } catch (e) {} + renderGenericFileCardPreview(cardObj, wrapper, src, img, state); + } + }; + + const applyDimensions = (width, height) => { + if (cardObj.dimEl && width && height) { + cardObj.dimEl.textContent = `${width}x${height}`; + cardObj.dimEl.style.display = "block"; + } + }; + + const isUnfinished = state.status ? (state.status !== "completed") : false; + if (isUnfinished) { + mediaEl.setAttribute("draggable", "false"); + mediaEl.style.cursor = "grab"; + if (mediaEl._currentDragStart) { + mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); + delete mediaEl._currentDragStart; + } + } else { + mediaEl.setAttribute("draggable", "true"); + mediaEl.style.cursor = "zoom-in"; + + if (isVideo) { + const fullSrc = src.startsWith("http") ? src : window.location.origin + src; + const filename = img.filename || "output.mp4"; + const mimeType = src.includes(".webm") ? "video/webm" : "video/mp4"; + + const videoDragHandler = (e) => { + try { + e.dataTransfer.setData("text/uri-list", fullSrc); + e.dataTransfer.setData("text/plain", fullSrc); + e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`); + if (state && state.workflow) { + e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); + } + } catch (err) {} + e.dataTransfer.effectAllowed = "copy"; + e.stopPropagation(); + }; + + if (mediaEl._currentDragStart) { + mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); + } + mediaEl.addEventListener("dragstart", videoDragHandler); + mediaEl._currentDragStart = videoDragHandler; + } else { + if (mediaEl._currentDragStart) { + mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart); + delete mediaEl._currentDragStart; + } + } + } + + if (isVideo) { + mediaEl.muted = true; + mediaEl.playsInline = true; + mediaEl.preload = "metadata"; + mediaEl.loop = true; + + mediaEl.onloadedmetadata = () => { + applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); + }; + if (mediaEl.readyState >= 1) { + applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); + } + + let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon"); + if (!playIcon) { + playIcon = document.createElement("div"); + playIcon.className = "comfy-sidebar-play-icon"; + Object.assign(playIcon.style, { + position: "absolute", top: "0", left: "0", width: "100%", height: "100%", + pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease" + }); + playIcon.innerHTML = ` + + + + + `; + wrapper.appendChild(playIcon); + } + + mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); }; + mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); }; + } else { + mediaEl.onload = () => { + applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); + if (state._oldPreviewBlobUrl) { + try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){} + delete state._oldPreviewBlobUrl; + } + }; + if (mediaEl.complete && mediaEl.naturalWidth) { + applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); + } + + const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon"); + if (playIcon) playIcon.remove(); + } + + const currentSrc = mediaEl.getAttribute("src") || mediaEl.src; + if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) { + if (src.startsWith("blob:") && !isVideo) { + const tempImg = new Image(); + tempImg.onload = () => { + const oldBlob = mediaEl._lastBlob; + mediaEl.src = src; + mediaEl._lastBlob = src; + applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight); + if (oldBlob && oldBlob !== src && oldBlob.startsWith("blob:")) { + try { URL.revokeObjectURL(oldBlob); } catch(e){} + } + }; + tempImg.src = src; + } else { + mediaEl.src = isVideo ? src + "#t=0.001" : src; + } + } else { + if (isVideo) { + if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight); + } else { + if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); + } + } + + let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar"); + if (state.images.length > 1) { + if (!navBar) { + navBar = document.createElement("div"); + navBar.className = "comfy-sidebar-batch-navbar"; + Object.assign(navBar.style, { + position: "absolute", bottom: "6px", left: "50%", + display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)", + padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px", + fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto", + boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)" + }); + + const prevBtn = document.createElement("span"); + prevBtn.className = "pi pi-chevron-left"; + prevBtn.style.cursor = "pointer"; + prevBtn.onclick = (ev) => { + ev.stopPropagation(); + cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length; + renderCardImages(cardObj, state, onNavigateBatch); + }; + + 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"; + + label.onclick = (ev) => { + ev.stopPropagation(); + if (typeof onNavigateBatch === "function") { + onNavigateBatch(state); + } + }; + + 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, onNavigateBatch); + }; + + navBar.append(prevBtn, label, nextBtn); + wrapper.appendChild(navBar); + } + + const label = navBar.querySelector(".comfy-sidebar-batch-label"); + if (label) label.textContent = `${idx + 1}/${state.images.length}`; + } else { + if (navBar) navBar.remove(); + } +} diff --git a/js/ui/sidebarView.js b/js/ui/sidebarView.js new file mode 100644 index 0000000..2641bb8 --- /dev/null +++ b/js/ui/sidebarView.js @@ -0,0 +1,912 @@ +import { app } from "/scripts/app.js"; +import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js"; +import { store } from "../core/store.js"; +import { getOrCreateCard, updateCardDOM, resetAllCardHoverStates, cardPool } from "./cardRenderer.js"; +import { clearCancelledOrFailed, clearAllHistory, cancelAllPending } from "../queue/queueService.js"; +import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.js"; +import { matchesFilter, getRunOutputs, isImageFormat } from "../utils/utils.js"; +import { renderCardImages } from "./mediaPreview.js"; +import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js"; + +let scrollToTopBtnEl = null; +let globalClickRegistered = false; +let renderAnimationFrameId = null; +let activeTimerInterval = null; + +function getScrollContainer() { + if (!store.ui.cardStack) return null; + return store.ui.cardStack.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]') + || store.ui.cardStack.parentElement + || store.ui.cardStack; +} + +function updateScrollTopBtnVisibility() { + if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return; + const scrollEl = getScrollContainer(); + if (scrollEl) { + const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer; + if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) { + if (window.getComputedStyle(viewportEl).position === "static") { + viewportEl.style.position = "relative"; + } + viewportEl.appendChild(scrollToTopBtnEl); + } + + if (scrollEl.scrollTop > 200) { + scrollToTopBtnEl.style.display = "flex"; + } else { + scrollToTopBtnEl.style.display = "none"; + } + } +} + +const handleGlobalKeyDown = (e) => { + if (e.key === "Control" || e.key === "Meta") { + document.body.classList.add("comfy-sidebar-ctrl-active"); + } +}; + +const handleGlobalKeyUp = (e) => { + if (e.key === "Control" || e.key === "Meta") { + document.body.classList.remove("comfy-sidebar-ctrl-active"); + } +}; + +const handleGlobalBlur = () => { + document.body.classList.remove("comfy-sidebar-ctrl-active"); +}; + +const handleGlobalClick = (e) => { + if (!store.ui.activeSubmenuPromptId && !store.ui.activeSubmenuBatchImages) { + document.removeEventListener("click", handleGlobalClick, true); + globalClickRegistered = false; + return; + } + const sidebar = store.ui.sidebarContainer; + const clickedInsideSidebar = sidebar && sidebar.contains(e.target); + const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay'); + + if (!clickedInsideSidebar && !clickedFullscreenOverlay) { + store.closeSubmenu(); + resetAllCardHoverStates(); + renderSidebar(); + document.removeEventListener("click", handleGlobalClick, true); + globalClickRegistered = false; + } +}; + +export function setupSidebarView() { + window.addEventListener("keydown", handleGlobalKeyDown); + window.addEventListener("keyup", handleGlobalKeyUp); + window.addEventListener("blur", handleGlobalBlur); + + const container = document.createElement("div"); + store.ui.sidebarContainer = container; + + Object.assign(container.style, { + display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box", + background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative" + }); + + const header = document.createElement("div"); + header.className = "comfy-sidebar-header-root"; + Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" }); + + const standardHeader = document.createElement("div"); + standardHeader.className = "comfy-sidebar-standard-header"; + Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" }); + + const titleGroup = document.createElement("div"); + Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" }); + + const searchIcon = document.createElement("span"); + searchIcon.className = "pi pi-search comfy-sidebar-icon-btn"; + searchIcon.title = "Search History"; + + const title = document.createElement("h3"); + title.textContent = "Queue"; + Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" }); + + titleGroup.appendChild(searchIcon); + titleGroup.appendChild(title); + standardHeader.appendChild(titleGroup); + + const actionsGroup = document.createElement("div"); + Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" }); + + const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => { + const btn = document.createElement("button"); + btn.className = `${iconClass} comfy-sidebar-header-btn`; + btn.title = tooltip; + + 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.addEventListener("click", 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); + } else { + reset(); + await onClickFn(); + } + }); + return btn; + }; + + const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => { + await clearCancelledOrFailed(); + renderSidebar(); + }); + + const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => { + await clearAllHistory(); + renderSidebar(); + }); + + actionsGroup.appendChild(btnClearInterrupted); + actionsGroup.appendChild(btnClearAll); + standardHeader.appendChild(actionsGroup); + + const searchContainer = document.createElement("div"); + searchContainer.className = "comfy-sidebar-search-container"; + 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" + }); + const searchInputIcon = document.createElement("span"); + searchInputIcon.className = "pi pi-search"; + Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" }); + const searchInput = document.createElement("input"); + Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." }); + Object.assign(searchInput.style, { + flex: "1", background: "transparent", border: "none", outline: "none", + color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0" + }); + const clearSearchBtn = document.createElement("span"); + clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn"; + clearSearchBtn.title = "Clear & Close Search"; + Object.assign(clearSearchBtn.style, { marginLeft: "6px" }); + + searchContainer.appendChild(searchInputIcon); + searchContainer.appendChild(searchInput); + searchContainer.appendChild(clearSearchBtn); + + header.appendChild(standardHeader); + header.appendChild(searchContainer); + container.appendChild(header); + + searchIcon.addEventListener("click", (e) => { + e.stopPropagation(); + standardHeader.style.display = "none"; + searchContainer.style.display = "flex"; + searchInput.focus(); + }); + + const closeSearch = () => { + searchInput.value = ""; + store.setSearchQuery(""); + searchContainer.style.display = "none"; + standardHeader.style.display = "flex"; + renderSidebar(); + }; + + clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); }); + searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); }); + searchInput.addEventListener("input", () => { + store.setSearchQuery(searchInput.value.trim()); + renderSidebar(); + }); + + const cardStack = document.createElement("div"); + store.ui.cardStack = cardStack; + Object.assign(cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" }); + container.appendChild(cardStack); + + scrollToTopBtnEl = document.createElement("button"); + scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn"; + scrollToTopBtnEl.title = "Scroll to Top"; + + scrollToTopBtnEl.addEventListener("click", (e) => { + e.stopPropagation(); + const scrollEl = getScrollContainer(); + if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" }); + }); + + container.appendChild(scrollToTopBtnEl); + + document.addEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true }); + + new ResizeObserver((entries) => { + const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350; + const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2))); + if (store.ui.cardStack) { + store.ui.cardStack.style.columnCount = cols.toString(); + store.ui.cardStack.style.columnGap = cols > 1 ? "12px" : "0"; + } + }).observe(container); + + activeTimerInterval = setInterval(() => { + const activePid = store.ui.currentlyActivePromptId; + if (!activePid) return; + const prompt = store.getPrompt(activePid); + if (prompt && prompt.status === PromptStatus.ACTIVE && prompt.startTime) { + const cardObj = cardPool.get(activePid); + if (cardObj && cardObj.timerEl) { + cardObj.timerEl.textContent = ((Date.now() - prompt.startTime) / 1000).toFixed(2) + "s"; + } + } + }, 250); + + store.on(StoreEvents.QUEUE_SYNCED, () => { + const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length; + const runningCount = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE).length; + updateSidebarBadge(pendingCount + (runningCount > 0 ? 1 : 0)); + renderSidebar(); + }); + + store.on(StoreEvents.PROMPT_ADDED, () => renderSidebar()); + store.on(StoreEvents.PROMPT_DELETED, () => renderSidebar()); + store.on(StoreEvents.PROMPTS_CLEARED, () => renderSidebar()); + store.on(StoreEvents.SUBMENU_CHANGED, () => renderSidebar()); + + return container; +} + +export function renderSidebar() { + if (renderAnimationFrameId) cancelAnimationFrame(renderAnimationFrameId); + renderAnimationFrameId = requestAnimationFrame(() => { + if (!store.ui.sidebarContainer || !store.ui.cardStack) return; + + const showPendingSummary = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_PENDING_COUNT_ONLY) ?? true; + const showWorkingNode = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_WORKING_NODE_NAME) ?? true; + + const headerTitle = store.ui.sidebarContainer.querySelector("h3"); + const headerSearchIcon = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-root .pi-search"); + const headerActions = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-btn")?.parentNode; + + // Submenu 1: Batch Images View + if (store.ui.activeSubmenuBatchImages) { + const batchInfo = store.ui.activeSubmenuBatchImages; + + if (headerTitle) { + headerTitle.textContent = `Batch of #${batchInfo.pid}`; + headerTitle.style.cursor = "pointer"; + headerTitle.title = "Go Back"; + headerTitle.onclick = () => { + if (batchInfo.parentPromptId) { + store.openOutputsSubmenu(batchInfo.parentPromptId); + } else { + store.closeSubmenu(); + } + resetAllCardHoverStates(); + renderSidebar(); + }; + } + 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 = cardPool.get(cardId); + + if (!cardObj) { + const card = document.createElement("div"); + card.id = `card-${cardId}`; + 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", transform: "translateZ(0)" + }); + + const grid = document.createElement("div"); + grid.style.display = "flex"; + grid.style.flexDirection = "column"; + grid.style.gap = "6px"; + + const p = document.createElement("div"); + p.className = "comfy-sidebar-text-clamp"; + + const hoverPanel = document.createElement("div"); + hoverPanel.className = "comfy-sidebar-hover-panel"; + Object.assign(hoverPanel.style, { + position: "absolute", bottom: "4px", right: "4px", + flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnImg = document.createElement("span"); + btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; + btnImg.title = "Download Object"; + btnImg.onclick = (ev) => { + ev.stopPropagation(); + const a = document.createElement("a"); + a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + a.download = img.filename || "output"; + a.click(); + }; + + const btnDel = document.createElement("span"); + btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; + btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)"; + const btnDelLabel = document.createElement("span"); + btnDelLabel.className = "comfy-sidebar-del-label"; + btnDelLabel.textContent = "Delete File"; + btnDel.appendChild(btnDelLabel); + + let isDeletePending = false, isDiskDelete = false, deleteTimeout = null; + const resetDelete = () => { + isDeletePending = false; + isDiskDelete = false; + btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); + btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)"; + if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } + }; + + btnDel.onclick = async (ev) => { + ev.stopPropagation(); + const wantsDiskDelete = ev.ctrlKey || ev.metaKey; + + if (!isDeletePending) { + isDeletePending = true; + isDiskDelete = wantsDiskDelete; + cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); + cardObj.btnDel.title = wantsDiskDelete + ? "Ctrl+Click again to delete file from DISK / TRASH" + : "Click again to confirm removing from batch"; + deleteTimeout = setTimeout(resetDelete, 2000); + } else { + const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; + resetDelete(); + + if (shouldDeleteFromDisk && img && img.filename) { + await deleteFileOnServer(img); + } + + const pid = batchInfo.parentPromptId || batchInfo.pid; + const state = store.getPrompt(pid); + const imgIdx = batchInfo.images.indexOf(img); + if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1); + + if (state) { + if (Array.isArray(state.images)) { + const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || "")); + if (stIdx > -1) state.images.splice(stIdx, 1); + } + if (state.nodeOutputs) { + removeImageFromNodeOutputs(state.nodeOutputs, img); + } + state.rendered = false; + store.updatePrompt(pid, state); + } + + const mainCardObj = cardPool.get(pid); + if (mainCardObj) { + mainCardObj.currentImageIndex = 0; + mainCardObj.lastImagesSignature = ""; + } + + if (batchInfo.images.length === 0) { + if (batchInfo.parentPromptId) { + store.openOutputsSubmenu(batchInfo.parentPromptId); + } else { + store.closeSubmenu(); + } + } + + renderSidebar(); + } + }; + + hoverPanel.append(btnImg, btnDel); + + const leftHoverPanel = document.createElement("div"); + leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; + Object.assign(leftHoverPanel.style, { + position: "absolute", bottom: "4px", left: "4px", + flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnCopy = document.createElement("span"); + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.title = "Copy to Clipboard"; + const isCopyable = isImageFormat(img.filename || img.url); + if (!isCopyable) { + btnCopy.style.display = "none"; + } else { + btnCopy.onclick = async (ev) => { + ev.stopPropagation(); + const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + const success = await copyImageToClipboard(src); + if (success) { + btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn"; + btnCopy.style.color = "#4ade80"; + setTimeout(() => { + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.style.color = ""; + }, 1500); + } + }; + } + + const btnFocus = document.createElement("span"); + btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; + btnFocus.title = "Show Node"; + const nodeId = findNodeIdForImage({ nodeOutputs: batchInfo.nodeOutputs }, img); + if (nodeId) { + btnFocus.onclick = (ev) => { + ev.stopPropagation(); + centerAndSelectCanvasNode(nodeId); + }; + } else { + btnFocus.style.display = "none"; + } + + leftHoverPanel.append(btnCopy, btnFocus); + card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel); + + cardObj = { + element: card, + timerEl, + dimEl, + grid, + placeholder: p, + hoverPanel, + leftHoverPanel, + btnImg, + btnCopy, + btnFocus, + btnDel, + firstImgElement: null + }; + + cardPool.set(cardId, cardObj); + } + + cardObj.placeholder.style.display = "none"; + renderCardImages(cardObj, { + pid: batchInfo.pid, + status: PromptStatus.COMPLETED, + images: [img], + workflow: batchInfo.workflow, + nodeOutputs: batchInfo.nodeOutputs + }); + + targetElements.push(cardObj.element); + }); + + targetElements.forEach((el, index) => { + if (store.ui.cardStack.children[index] !== el) { + store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null); + } + }); + while (store.ui.cardStack.children.length > targetElements.length) { + store.ui.cardStack.removeChild(store.ui.cardStack.lastChild); + } + + const scrollEl = getScrollContainer(); + if (scrollEl) scrollEl.scrollTop = 0; + updateScrollTopBtnVisibility(); + return; + } + + // Submenu 2: Intermediate Outputs View + if (store.ui.activeSubmenuPromptId) { + const st = store.getPrompt(store.ui.activeSubmenuPromptId); + if (!st) { + store.closeSubmenu(); + renderSidebar(); + return; + } + + if (headerTitle) { + headerTitle.textContent = `Outputs of #${store.ui.activeSubmenuPromptId}`; + headerTitle.style.cursor = "pointer"; + headerTitle.title = "Go Back to Queue"; + headerTitle.onclick = () => { + store.closeSubmenu(); + resetAllCardHoverStates(); + renderSidebar(); + }; + } + if (headerSearchIcon) headerSearchIcon.style.display = "none"; + if (headerActions) headerActions.style.display = "none"; + + if (!globalClickRegistered) { + document.addEventListener("click", handleGlobalClick, true); + globalClickRegistered = true; + } + + const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow); + const seenFiles = new Set(); + const outputs = []; + + rawOutputs.forEach(out => { + const uniqueImgs = (out.images || []).filter(img => { + const key = `${img.subfolder || ""}/${img.filename}`; + if (seenFiles.has(key)) return false; + seenFiles.add(key); + return true; + }); + if (uniqueImgs.length > 0) { + outputs.push({ nodeId: out.nodeId, images: uniqueImgs, nodeTitle: out.nodeTitle }); + } + }); + + const targetElements = []; + + if (outputs.length === 0) { + const emptyCard = document.createElement("div"); + emptyCard.className = "comfy-sidebar-card completed"; + Object.assign(emptyCard.style, { + padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px" + }); + emptyCard.textContent = "No separate intermediate outputs found."; + targetElements.push(emptyCard); + } else { + outputs.forEach((out) => { + const cardId = `submenu-${st.pid}-${out.nodeId}`; + let cardObj = cardPool.get(cardId); + + if (!cardObj) { + const card = document.createElement("div"); + card.id = `card-${cardId}`; + card.className = "comfy-sidebar-card completed"; + card.style.position = "relative"; + + const titleFromState = st.nodeTitles?.[out.nodeId]; + const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId)); + const resolvedTitle = titleFromState || (node ? (node.title || node.type) : out.nodeTitle) || "Node"; + + const timerEl = document.createElement("div"); + timerEl.className = "comfy-sidebar-card-timer"; + timerEl.textContent = `${resolvedTitle} (#${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", transform: "translateZ(0)" + }); + + const grid = document.createElement("div"); + grid.style.display = "flex"; + grid.style.flexDirection = "column"; + grid.style.gap = "6px"; + + const p = document.createElement("div"); + p.className = "comfy-sidebar-text-clamp"; + + const hoverPanel = document.createElement("div"); + hoverPanel.className = "comfy-sidebar-hover-panel"; + Object.assign(hoverPanel.style, { + position: "absolute", bottom: "4px", right: "4px", + flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnImg = document.createElement("span"); + btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; + btnImg.title = "Download Object"; + 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 || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`; + a.download = img.filename || "output"; + a.click(); + }); + }; + + const btnDel = document.createElement("span"); + btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del"; + btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)"; + const btnDelLabel = document.createElement("span"); + btnDelLabel.className = "comfy-sidebar-del-label"; + btnDelLabel.textContent = "Delete File"; + btnDel.appendChild(btnDelLabel); + + let isDeletePending = false, isDiskDelete = false, deleteTimeout = null; + const resetDelete = () => { + isDeletePending = false; + isDiskDelete = false; + btnDel.classList.remove("confirm-delete", "confirm-delete-disk"); + btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)"; + if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; } + }; + + btnDel.onclick = async (ev) => { + ev.stopPropagation(); + const wantsDiskDelete = ev.ctrlKey || ev.metaKey; + + if (!isDeletePending) { + isDeletePending = true; + isDiskDelete = wantsDiskDelete; + btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete"); + btnDel.title = wantsDiskDelete + ? "Ctrl+Click again to delete this node's files from DISK / TRASH" + : "Click again to confirm removing output"; + deleteTimeout = setTimeout(resetDelete, 2000); + } else { + const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete; + resetDelete(); + + if (shouldDeleteFromDisk && out.images) { + for (const imgItem of out.images) { + await deleteFileOnServer(imgItem); + } + } + + if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) { + delete st.nodeOutputs[out.nodeId]; + } + + const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow); + if (remainingOutputs.length > 0) { + st.images = remainingOutputs[remainingOutputs.length - 1].images || []; + } else { + st.images = []; + store.closeSubmenu(); + } + + store.updatePrompt(st.pid, st); + renderSidebar(); + } + }; + + hoverPanel.append(btnImg, btnDel); + + const leftHoverPanel = document.createElement("div"); + leftHoverPanel.className = "comfy-sidebar-left-hover-panel"; + Object.assign(leftHoverPanel.style, { + position: "absolute", bottom: "4px", left: "4px", + flexDirection: "column", gap: "4px", zIndex: "20" + }); + + const btnCopy = document.createElement("span"); + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.title = "Copy to Clipboard"; + const firstImg = out.images[0]; + const isCopyable = firstImg && isImageFormat(firstImg.filename || firstImg.url); + if (!isCopyable) { + btnCopy.style.display = "none"; + } else { + btnCopy.onclick = async (ev) => { + ev.stopPropagation(); + const src = firstImg.url ? firstImg.url : `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}`; + const success = await copyImageToClipboard(src); + if (success) { + btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn"; + btnCopy.style.color = "#4ade80"; + setTimeout(() => { + btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn"; + btnCopy.style.color = ""; + }, 1500); + } + }; + } + + const btnFocus = document.createElement("span"); + btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn"; + btnFocus.title = "Show Node"; + btnFocus.onclick = (ev) => { + ev.stopPropagation(); + centerAndSelectCanvasNode(out.nodeId); + }; + + leftHoverPanel.append(btnCopy, btnFocus); + card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel); + + cardObj = { + element: card, + timerEl, + dimEl, + grid, + placeholder: p, + hoverPanel, + leftHoverPanel, + btnImg, + btnCopy, + btnFocus, + btnDel, + firstImgElement: null + }; + + cardPool.set(cardId, cardObj); + } + + if (out.images && out.images.length > 0) { + cardObj.placeholder.style.display = "none"; + renderCardImages(cardObj, { + pid: st.pid, + status: PromptStatus.COMPLETED, + images: out.images, + workflow: st.workflow, + nodeOutputs: st.nodeOutputs + }, () => { + // Clicking (1/N) opens batch drill-down for this specific intermediate node + const scrollEl = getScrollContainer(); + if (scrollEl) { + store.ui.mainQueueScrollTop = scrollEl.scrollTop; + } + const nodeTitle = st.nodeTitles?.[out.nodeId] || out.nodeTitle || "Node"; + store.openBatchSubmenu({ + pid: `${st.pid} - ${nodeTitle} (#${out.nodeId})`, + parentPromptId: st.pid, + images: out.images, + workflow: st.workflow, + nodeOutputs: { [out.nodeId]: st.nodeOutputs?.[out.nodeId] } + }); + }); + } else { + cardObj.placeholder.style.display = "block"; + cardObj.placeholder.textContent = "No Outputs"; + } + + targetElements.push(cardObj.element); + }); + } + + targetElements.forEach((el, index) => { + if (store.ui.cardStack.children[index] !== el) { + store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null); + } + }); + while (store.ui.cardStack.children.length > targetElements.length) { + store.ui.cardStack.removeChild(store.ui.cardStack.lastChild); + } + + updateScrollTopBtnVisibility(); + return; + } + + // Main Queue View + 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 = store.getAllPrompts(); + if (showPendingSummary) { + tasksArray = tasksArray.filter(t => t.status !== PromptStatus.PENDING); + } + if (store.ui.searchQuery) { + tasksArray = tasksArray.filter(t => matchesFilter(t, store.ui.searchQuery)); + } + tasksArray.sort((a, b) => b.timestamp - a.timestamp); + + const activeTasks = tasksArray.filter(t => t.status === PromptStatus.ACTIVE); + const completedTasks = tasksArray.filter(t => t.status === PromptStatus.COMPLETED || t.status === PromptStatus.CANCELLED || t.status === PromptStatus.ERROR); + const pendingTasks = tasksArray.filter(t => t.status === PromptStatus.PENDING).sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0)); + + const syncTaskCard = (state) => { + const cardObj = getOrCreateCard(state); + return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode); + }; + + const targetElements = []; + const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length; + + if (pendingCount > 0) { + if (showPendingSummary) { + let pCard = cardPool.get("pending-summary-card"); + if (!pCard) { + const el = document.createElement("div"); + Object.assign(el.style, { + background: "var(--comfy-input-bg, #181818)", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px", + marginBottom: "12px", textAlign: "center", fontSize: "12px", fontWeight: "bold", + color: "var(--desc-color, #aaa)", breakInside: "avoid", display: "flex", flexDirection: "column", gap: "8px" + }); + const textDiv = document.createElement("div"); + el.appendChild(textDiv); + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel All Pending"; + Object.assign(cancelBtn.style, { + background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px", + cursor: "pointer", fontSize: "11px", fontWeight: "bold" + }); + cancelBtn.onclick = async () => { + await cancelAllPending(); + }; + el.appendChild(cancelBtn); + pCard = { element: el, textDiv }; + cardPool.set("pending-summary-card", pCard); + } + pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`; + targetElements.push(pCard.element); + } else { + let btnCard = cardPool.get("pending-cancel-all-standalone"); + if (!btnCard) { + const btn = document.createElement("button"); + btn.textContent = "Cancel All Pending"; + Object.assign(btn.style, { + background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px", + cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%", marginBottom: "12px", breakInside: "avoid" + }); + btn.onclick = async () => { + await cancelAllPending(); + }; + btnCard = { element: btn }; + cardPool.set("pending-cancel-all-standalone", btnCard); + } + targetElements.push(btnCard.element); + pendingTasks.forEach(st => targetElements.push(syncTaskCard(st))); + } + } + + activeTasks.forEach(st => targetElements.push(syncTaskCard(st))); + completedTasks.forEach(st => targetElements.push(syncTaskCard(st))); + + targetElements.forEach((el, index) => { + if (store.ui.cardStack.children[index] !== el) { + store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null); + } + }); + while (store.ui.cardStack.children.length > targetElements.length) { + store.ui.cardStack.removeChild(store.ui.cardStack.lastChild); + } + + if (store.ui.mainQueueScrollTop !== null) { + const restorePos = store.ui.mainQueueScrollTop; + store.ui.mainQueueScrollTop = null; + const scrollEl = getScrollContainer(); + if (scrollEl) { + scrollEl.scrollTop = restorePos; + requestAnimationFrame(() => { scrollEl.scrollTop = restorePos; }); + } + } + + updateScrollTopBtnVisibility(); + }); +} + +export function teardownSidebarView() { + window.removeEventListener("keydown", handleGlobalKeyDown); + window.removeEventListener("keyup", handleGlobalKeyUp); + window.removeEventListener("blur", handleGlobalBlur); + document.removeEventListener("click", handleGlobalClick, true); + document.removeEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true }); + + if (activeTimerInterval) { + clearInterval(activeTimerInterval); + activeTimerInterval = null; + } + if (renderAnimationFrameId) { + cancelAnimationFrame(renderAnimationFrameId); + renderAnimationFrameId = null; + } + cardPool.clear(); +} \ No newline at end of file diff --git a/js/comparison.js b/js/utils/comparison.js similarity index 96% rename from js/comparison.js rename to js/utils/comparison.js index 5c088b3..b751c1b 100644 --- a/js/comparison.js +++ b/js/utils/comparison.js @@ -1,7 +1,7 @@ import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js"; import { create3DViewer } from "./viewer3d.js"; import { createTextReader } from "./text_reader.js"; -import { stopAllAudioPlayback } from "./ui.js"; +import { stopAllAudioPlayback } from "../ui/audioController.js"; import { SidebarOverlay } from "./overlay.js"; let activeComparisonViewer = null; @@ -264,10 +264,14 @@ const setupVideoPlayback = (vid, container) => { const controlBar = document.createElement("div"); Object.assign(controlBar.style, { position: "absolute", bottom: "16px", left: "50%", - display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)", + display: "flex", alignItems: "center", gap: "12px", + background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.9))", + border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))", padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px", - fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px", - boxShadow: "0 2px 8px rgba(0,0,0,0.5)", transform: "translate3d(-50%, 0, 0)", + fontFamily: "monospace", + color: "var(--fg-color, #eee)", + width: "80%", maxWidth: "500px", + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)", pointerEvents: "auto" }); @@ -380,10 +384,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) { const header = document.createElement("div"); Object.assign(header.style, { position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)", - display: "flex", gap: "16px", zIndex: "30", color: "#aaa", fontSize: "12px", - fontFamily: "sans-serif", pointerEvents: "none", background: "rgba(10,10,10,0.75)", - padding: "4px 10px", borderRadius: "4px", backdropFilter: "blur(4px)", - boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center" + display: "flex", gap: "16px", zIndex: "30", + color: "var(--desc-color, #aaa)", + fontSize: "12px", + fontFamily: "sans-serif", pointerEvents: "none", + background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.85))", + border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))", + padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(6px)", + boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center" }); const infoText = document.createElement("span"); infoText.textContent = isBaseVideo diff --git a/js/dragdrop.js b/js/utils/dragdrop.js similarity index 97% rename from js/dragdrop.js rename to js/utils/dragdrop.js index b6bc07b..ed368c7 100644 --- a/js/dragdrop.js +++ b/js/utils/dragdrop.js @@ -1,5 +1,5 @@ import { app } from "/scripts/app.js"; -import { State } from "./state.js"; +import { store } from "../core/store.js"; async function promoteOrUploadToInput(imageObj) { // 1. Try server-side promotion first (no browser RAM/re-upload overhead) @@ -145,7 +145,7 @@ export function setupDragAndDrop() { const onDrop = async (e) => { const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') || - (State.sidebarContainer && State.sidebarContainer.contains(e.target)); + (store.ui.sidebarContainer && store.ui.sidebarContainer.contains(e.target)); if (isSidebarDrop) { e.preventDefault(); e.stopPropagation(); diff --git a/js/overlay.js b/js/utils/overlay.js similarity index 73% rename from js/overlay.js rename to js/utils/overlay.js index 9a82b19..f3e9dcb 100644 --- a/js/overlay.js +++ b/js/utils/overlay.js @@ -1,4 +1,4 @@ -import { State } from "./state.js"; +import { store } from "../core/store.js"; export class SidebarOverlay { constructor(options = {}) { @@ -10,7 +10,9 @@ export class SidebarOverlay { this.container.className = `comfy-sidebar-comparison-overlay ${options.className || ""}`; Object.assign(this.container.style, { position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", - background: options.background || "rgba(10, 10, 10, 0.95)", + background: options.background || "color-mix(in srgb, var(--comfy-menu-bg, #121212) 95%, transparent)", + backdropFilter: "blur(8px)", + color: "var(--fg-color, #eee)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", zIndex: "1000", boxSizing: "border-box", overflow: "hidden", pointerEvents: "auto", userSelect: "none" @@ -22,11 +24,22 @@ export class SidebarOverlay { this.closeBtn.title = "Close (Esc)"; Object.assign(this.closeBtn.style, { position: "absolute", top: "16px", right: "24px", zIndex: "30", - cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease", - background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px" + cursor: "pointer", fontSize: "18px", + color: "var(--desc-color, #aaa)", + 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" }); - this.closeBtn.onmouseenter = () => this.closeBtn.style.color = "#fff"; - this.closeBtn.onmouseleave = () => this.closeBtn.style.color = "#aaa"; + this.closeBtn.onmouseenter = () => { + this.closeBtn.style.color = "var(--fg-color, #fff)"; + this.closeBtn.style.borderColor = "var(--fg-color, #fff)"; + }; + this.closeBtn.onmouseleave = () => { + this.closeBtn.style.color = "var(--desc-color, #aaa)"; + this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))"; + }; + this.closeBtn.onclick = () => this.destroy(); this.container.appendChild(this.closeBtn); @@ -41,7 +54,7 @@ export class SidebarOverlay { window.addEventListener("resize", this.updateOverlayBounds); this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds)); - const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer; + 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); @@ -63,7 +76,7 @@ export class SidebarOverlay { } updateOverlayBounds() { - const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer; + 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) { diff --git a/js/styles.js b/js/utils/styles.js similarity index 100% rename from js/styles.js rename to js/utils/styles.js diff --git a/js/text_reader.js b/js/utils/text_reader.js similarity index 95% rename from js/text_reader.js rename to js/utils/text_reader.js index 5620ac4..004fcf8 100644 --- a/js/text_reader.js +++ b/js/utils/text_reader.js @@ -201,11 +201,14 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = const readerBox = document.createElement("div"); Object.assign(readerBox.style, { - background: "rgba(18, 18, 24, 0.95)", backdropFilter: "blur(12px)", - border: "1px solid rgba(255, 255, 255, 0.15)", borderRadius: "12px", + background: "var(--comfy-menu-bg, rgba(18, 18, 24, 0.95))", + backdropFilter: "blur(12px)", + border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))", + borderRadius: "12px", padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh", - boxShadow: "0 12px 40px rgba(0,0,0,0.8)", display: "flex", - flexDirection: "column", gap: "14px", zIndex: "20" + boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex", + flexDirection: "column", gap: "14px", zIndex: "20", + color: "var(--fg-color, #eee)" }); const headerRow = document.createElement("div"); @@ -258,10 +261,14 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = const textArea = document.createElement("div"); Object.assign(textArea.style, { flex: "1", overflowY: "auto", fontSize: "13px", - lineHeight: "1.6", color: "#e2e8f0", wordBreak: "break-word", - userSelect: "text", "-webkit-user-select": "text", background: "rgba(0,0,0,0.35)", - padding: "16px 20px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.06)", - scrollbarWidth: "thin", scrollbarColor: "#475569 rgba(0,0,0,0.2)" + lineHeight: "1.6", + color: "var(--fg-color, #e2e8f0)", + wordBreak: "break-word", + userSelect: "text", "-webkit-user-select": "text", + background: "var(--comfy-input-bg, rgba(0, 0, 0, 0.2))", + padding: "16px 20px", borderRadius: "6px", + border: "1px solid var(--border-color, rgba(255, 255, 255, 0.08))", + scrollbarWidth: "thin" }); const updateView = () => { diff --git a/js/utils.js b/js/utils/utils.js similarity index 59% rename from js/utils.js rename to js/utils/utils.js index 1f8d59f..12fa2df 100644 --- a/js/utils.js +++ b/js/utils/utils.js @@ -2,52 +2,62 @@ import { app } from "/scripts/app.js"; const FILE_EXT_REGEX = /\.(safetensors|ckpt|pt|pth|bin|latent|onnx|engine|gguf|lora|zip|tar|gz|7z|json|csv|parquet|txt|yaml|yml|xml|pdf|blend|fbx|obj|glb|gltf|stl|ply|splat|spz|ksplat|png|jpg|jpeg|webp|gif|bmp|tiff|svg|mp4|webm|wav|mp3|ogg|flac|m4a|aac|opus)$/i; -export const isImageFormat = (url) => { - if (!url) return false; - if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true; - const s = String(url).toLowerCase().split("?")[0]; - return s.endsWith(".png") || s.endsWith(".jpg") || s.endsWith(".jpeg") || - s.endsWith(".webp") || s.endsWith(".gif") || s.endsWith(".bmp") || - s.endsWith(".tiff") || s.endsWith(".tif") || s.endsWith(".svg") || - s.endsWith(".avif") || s.endsWith(".ico") || s.endsWith(".apng"); -}; - -export const isVideoFormat = (url) => { - if (!url) return false; - const s = String(url).toLowerCase().split("?")[0]; - return s.endsWith(".mp4") || s.endsWith(".webm") || s.endsWith(".mov") || s.endsWith(".mkv"); -}; - -export const is3DFormat = (url) => { - if (!url) return false; - const s = String(url).toLowerCase().split("?")[0]; - return s.endsWith(".glb") || s.endsWith(".gltf") || s.endsWith(".obj") || - s.endsWith(".ply") || s.endsWith(".stl") || s.endsWith(".splat") || - s.endsWith(".spz") || s.endsWith(".ksplat") || s.endsWith(".fbx"); -}; - -export const isAudioFormat = (url) => { - if (!url) return false; - const s = String(url).toLowerCase().split("?")[0]; - return s.endsWith(".wav") || s.endsWith(".mp3") || s.endsWith(".ogg") || - s.endsWith(".flac") || s.endsWith(".m4a") || s.endsWith(".aac") || - s.endsWith(".opus"); -}; - export function getFilenameFromUrl(url) { if (!url) return ""; + if (typeof url === "object") { + url = url.filename || url.url || ""; + } + const str = String(url); try { - const u = new URL(url, window.location.origin); + const origin = window.location?.origin && window.location.origin !== "null" ? window.location.origin : "http://localhost"; + const u = new URL(str, origin); if (u.searchParams && u.searchParams.get("filename")) { return u.searchParams.get("filename"); } const pathname = u.pathname || ""; return pathname.split("/").pop() || ""; } catch (e) { - return String(url).split("?")[0].split("/").pop() || ""; + const m = str.match(/[?&]filename=([^&#]+)/); + if (m) { + try { return decodeURIComponent(m[1]); } catch (err) { return m[1]; } + } + return str.split("?")[0].split("#")[0].split("/").pop() || ""; } } +const checkExt = (url, extensions) => { + if (!url) return false; + const name = getFilenameFromUrl(url).toLowerCase(); + const s = String(url).toLowerCase().split("?")[0].split("#")[0]; + return extensions.some(ext => name.endsWith(ext) || s.endsWith(ext)); +}; + +export const isImageFormat = (url) => { + if (!url) return false; + if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true; + return checkExt(url, [ + ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", + ".tiff", ".tif", ".svg", ".avif", ".ico", ".apng" + ]); +}; + +export const isVideoFormat = (url) => { + return checkExt(url, [".mp4", ".webm", ".mov", ".mkv"]); +}; + +export const is3DFormat = (url) => { + return checkExt(url, [ + ".glb", ".gltf", ".obj", ".ply", ".stl", + ".splat", ".spz", ".ksplat", ".fbx" + ]); +}; + +export const isAudioFormat = (url) => { + return checkExt(url, [ + ".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", ".opus" + ]); +}; + export function parseWorkflow(workflow) { if (!workflow) return null; if (typeof workflow === "object") return workflow; @@ -57,28 +67,66 @@ export function parseWorkflow(workflow) { return null; } -function isNodeIgnored(nodeId, rawWorkflow) { - // 1. Check live canvas node property first - if (app.graph) { +export function isNodeIgnored(nodeId, rawWorkflow) { + const workflow = parseWorkflow(rawWorkflow); + if (workflow && Array.isArray(workflow.nodes)) { + const node = workflow.nodes.find(n => String(n.id) === String(nodeId)); + if (node?.properties?.ignoreInQueue !== undefined) { + return !!node.properties.ignoreInQueue; + } + } + + if (!workflow && app.graph) { const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(String(nodeId))) : null; if (canvasNode?.properties?.ignoreInQueue !== undefined) { return !!canvasNode.properties.ignoreInQueue; } } - // 2. Fallback to historical workflow snapshot - const workflow = parseWorkflow(rawWorkflow); - if (!workflow || !Array.isArray(workflow.nodes)) { - return false; - } - const node = workflow.nodes.find(n => String(n.id) === String(nodeId)); - return !!(node && node.properties && node.properties.ignoreInQueue); + return false; } -function extractFileCandidate(val, subfolder = "", type = "output") { +export function getNodeTitle(nodeId, rawWorkflow, promptGraph = null) { + const idStr = String(nodeId); + + const workflow = parseWorkflow(rawWorkflow); + if (workflow && Array.isArray(workflow.nodes)) { + const node = workflow.nodes.find(n => String(n.id) === idStr); + if (node) { + return node.title || node.type || null; + } + } + + if (promptGraph && promptGraph[idStr]) { + const pNode = promptGraph[idStr]; + if (pNode._meta?.title) return pNode._meta.title; + if (pNode.class_type) return pNode.class_type; + } + + if (app.graph) { + const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(idStr)) : null; + if (canvasNode) { + return canvasNode.title || canvasNode.type || null; + } + } + + return null; +} + +function cleanSubfolder(subfolder) { + if (!subfolder) return ""; + let s = String(subfolder).replace(/\\/g, "/").trim(); + s = s.replace(/^\/+|\/+$/g, ""); // Strip leading/trailing slashes + if (s.toLowerCase() === "output") return ""; + if (s.toLowerCase().startsWith("output/")) { + s = s.substring(7); + } + return s; +} + +function extractFileCandidate(val, subfolder = "", type = "output", nodeType = "") { if (!val || typeof val !== "string") return null; - const trimmed = val.trim(); + let trimmed = val.replace(/\\/g, "/").trim(); - // 1. Ignore directory dots, floats/numbers, and empty strings if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") { return null; } @@ -86,11 +134,31 @@ function extractFileCandidate(val, subfolder = "", type = "output") { return null; } - // 2. Extract base file name - const cleanName = trimmed.split("/").pop()?.split("\\").pop()?.trim() || ""; + // Extract any path ending with a known media or model extension from text messages + const fileMatch = trimmed.match(/([a-zA-Z0-9_\-\.\/]+\.(?:safetensors|ckpt|pt|bin|latent|png|jpg|jpeg|webp|mp4|webm|wav|mp3|glb|gltf))/i); + if (fileMatch) { + trimmed = fileMatch[1]; + } + + // Auto-append .safetensors if this is a LoRA node/subfolder without an extension + if (!trimmed.includes(".")) { + const typeLower = (nodeType || "").toLowerCase(); + const subLower = (subfolder || "").toLowerCase(); + const isLora = typeLower.includes("lora") || subLower.includes("lora") || trimmed.includes("lora"); + const isLatent = typeLower.includes("latent"); + + if (isLora) { + trimmed += ".safetensors"; + } else if (isLatent) { + trimmed += ".latent"; + } else { + return null; + } + } + + const cleanName = trimmed.split("/").pop()?.trim() || ""; if (!cleanName || cleanName === "." || cleanName === "..") return null; - // 3. Must have a dot with real name before and after (e.g. "model.safetensors") const dotIndex = cleanName.lastIndexOf("."); if (dotIndex <= 0 || dotIndex === cleanName.length - 1) { return null; @@ -100,53 +168,58 @@ function extractFileCandidate(val, subfolder = "", type = "output") { const base = cleanName.substring(0, dotIndex).trim(); if (!base || !ext) return null; - // 4. Must match a known extension or a valid 1-8 char alphanumeric extension without whitespace const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100); + if (!isValidExt) return null; - if (isValidExt) { - return { - filename: cleanName, - subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""), - type: type || "output" - }; + let detectedSub = subfolder; + if (trimmed.includes("/")) { + detectedSub = trimmed.substring(0, trimmed.lastIndexOf("/")); } - return null; + detectedSub = cleanSubfolder(detectedSub); + + return { + filename: cleanName, + subfolder: detectedSub, + type: type || "output" + }; } export function findImagesInOutputs(outputs, rawWorkflow) { const list = []; const seen = new Set(); + const workflow = parseWorkflow(rawWorkflow); const addCandidate = (item) => { if (!item || !item.filename) return; - const key = `${item.subfolder || ""}/${item.filename}`; + const key = `${item.type || "output"}/${item.subfolder || ""}/${item.filename}`; if (!seen.has(key)) { seen.add(key); list.push(item); } }; - const scan = (obj, sub = "", typ = "output") => { + const scan = (obj, sub = "", typ = "output", nodeType = "") => { if (!obj) return; if (typeof obj === "string") { - const cand = extractFileCandidate(obj, sub, typ); + const cand = extractFileCandidate(obj, sub, typ, nodeType); if (cand) addCandidate(cand); return; } if (Array.isArray(obj)) { - obj.forEach(item => scan(item, sub, typ)); + obj.forEach(item => scan(item, sub, typ, nodeType)); return; } if (typeof obj === "object") { - const targetSub = obj.subfolder || sub || ""; + const targetSub = obj.subfolder !== undefined ? obj.subfolder : sub; const targetTyp = obj.type || typ || "output"; const fname = obj.filename || obj.name || obj.file || obj.path || obj.lora_name || obj.model_name || obj.save_name || - obj.saved_file || obj.output_path || obj.file_name; + obj.saved_file || obj.output_path || obj.file_name || + obj.output || obj.saved; if (fname && typeof fname === "string") { - const cand = extractFileCandidate(fname, targetSub, targetTyp); + const cand = extractFileCandidate(fname, targetSub, targetTyp, nodeType); if (cand) { addCandidate(cand); return; @@ -154,48 +227,51 @@ export function findImagesInOutputs(outputs, rawWorkflow) { } for (const key in obj) { - scan(obj[key], targetSub, targetTyp); + scan(obj[key], targetSub, targetTyp, nodeType || key); } } }; - const workflow = parseWorkflow(rawWorkflow); - if (outputs) { for (const nodeId in outputs) { if (isNodeIgnored(nodeId, workflow)) continue; - scan(outputs[nodeId]); + const node = workflow?.nodes?.find(n => String(n.id) === String(nodeId)); + const nodeType = node ? (node.type || "") : ""; + scan(outputs[nodeId], "", "output", nodeType); } } if (list.length > 0) return list; - // Fallback: If scanning a specific node ID, only look at that matching node in the workflow + // Fallback: Check workflow nodes for Save / Extract / Export nodes if (workflow && Array.isArray(workflow.nodes)) { const targetNodeIds = outputs ? Object.keys(outputs).map(String) : null; for (const node of workflow.nodes) { if (node && !node.properties?.ignoreInQueue) { - if (targetNodeIds && !targetNodeIds.includes(String(node.id))) { + if (targetNodeIds && targetNodeIds.length > 0 && !targetNodeIds.includes(String(node.id))) { continue; } const typeStr = (node.type || "").toLowerCase(); const isLoaderNode = typeStr.includes("load") || typeStr.includes("loader") || typeStr.includes("input"); - const isSaveNode = !isLoaderNode && (typeStr.includes("save") || typeStr.includes("extract") || - typeStr.includes("export") || typeStr.includes("writer")); - + const isSaveNode = !isLoaderNode && ( + typeStr.includes("save") || + typeStr.includes("extract") || + typeStr.includes("export") || + typeStr.includes("writer") + ); + if (isSaveNode && Array.isArray(node.widgets_values)) { for (const val of node.widgets_values) { if (typeof val === "string" && val.trim().length > 0) { - let fname = val.trim(); - if (typeStr.includes("lora") && !fname.includes(".")) { - fname += ".safetensors"; - } else if (typeStr.includes("latent") && !fname.includes(".")) { - fname += ".latent"; + let defaultSub = ""; + if (typeStr.includes("lora")) defaultSub = "loras"; + const cand = extractFileCandidate(val, defaultSub, "output", typeStr); + if (cand) { + cand.isFallback = true; + addCandidate(cand); } - const cand = extractFileCandidate(fname, typeStr.includes("lora") ? "loras" : "", "output"); - if (cand) addCandidate(cand); } } } @@ -234,19 +310,26 @@ export function findTextsInOutputs(outputs, rawWorkflow) { return list; } -export function getRunOutputs(nodeOutputs, rawWorkflow) { +export function getRunOutputs(nodeOutputs, rawWorkflow, promptGraph = null) { const workflow = parseWorkflow(rawWorkflow); const list = []; if (!nodeOutputs) return list; for (const nodeId in nodeOutputs) { + if (isNodeIgnored(nodeId, workflow)) continue; const nodeOut = nodeOutputs[nodeId]; - // Ignore empty node outputs if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) { + // Even if the output dict is empty, check if this node was a Save/Extract node + const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow); + if (imgs.length > 0) { + const title = getNodeTitle(nodeId, workflow, promptGraph); + list.push({ nodeId, images: imgs, nodeTitle: title }); + } continue; } const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow); if (imgs.length > 0) { - list.push({ nodeId, images: imgs }); + const title = getNodeTitle(nodeId, workflow, promptGraph); + list.push({ nodeId, images: imgs, nodeTitle: title }); } } return list; @@ -291,7 +374,6 @@ export async function extractWorkflowFromPng(imageUrl) { const buffer = await res.arrayBuffer(); const view = new DataView(buffer); - // Verify PNG signature (89 50 4E 47 0D 0A 1A 0A) if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) { return null; } @@ -317,7 +399,7 @@ export async function extractWorkflowFromPng(imageUrl) { break; } - offset += 12 + length; // 4 (length) + 4 (type) + length (data) + 4 (crc) + offset += 12 + length; } } catch (e) { console.warn("Comfy Sidebar: Could not extract workflow from PNG", e); diff --git a/js/viewer3d.js b/js/utils/viewer3d.js similarity index 85% rename from js/viewer3d.js rename to js/utils/viewer3d.js index 1b5f3ff..470092b 100644 --- a/js/viewer3d.js +++ b/js/utils/viewer3d.js @@ -1,35 +1,65 @@ -import { is3DFormat } from "./utils.js"; +import { is3DFormat, getFilenameFromUrl } from "./utils.js"; import { SidebarOverlay } from "./overlay.js"; let cachedThreeLibs = null; async function getThreeLibs() { if (cachedThreeLibs) return cachedThreeLibs; - if (window.THREE && window.THREE.GLTFLoader && window.THREE.OrbitControls) { - cachedThreeLibs = window.THREE; - return cachedThreeLibs; - } - try { - const [three, controls, gltf, obj, stl, ply] = await Promise.all([ - import("https://esm.sh/three@0.170.0"), - import("https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js"), - import("https://esm.sh/three@0.170.0/examples/jsm/loaders/GLTFLoader.js"), - import("https://esm.sh/three@0.170.0/examples/jsm/loaders/OBJLoader.js"), - import("https://esm.sh/three@0.170.0/examples/jsm/loaders/STLLoader.js"), - import("https://esm.sh/three@0.170.0/examples/jsm/loaders/PLYLoader.js") - ]); + // Check if Three.js was already loaded on window by another ComfyUI 3D node + if (window.THREE) { + const OrbitControls = window.THREE.OrbitControls || window.OrbitControls; + const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader; + if (OrbitControls && GLTFLoader) { + cachedThreeLibs = { + ...window.THREE, + OrbitControls, + GLTFLoader, + OBJLoader: window.THREE.OBJLoader || window.OBJLoader, + STLLoader: window.THREE.STLLoader || window.STLLoader, + PLYLoader: window.THREE.PLYLoader || window.PLYLoader + }; + return cachedThreeLibs; + } + } + + const loadFromBase = async (base) => { + const [three, controls, gltf, obj, stl, ply] = await Promise.all([ + import(`${base}three@0.170.0`), + import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`), + import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`), + import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null), + import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null), + import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null) + ]); + return { three, controls, gltf, obj, stl, ply }; + }; + + try { + let loaded = null; + // 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes) + try { + loaded = await loadFromBase("/comfy-sidebar/three-proxy/"); + window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/"; + } catch (proxyErr) { + // 2. Fall back to direct CDN if proxy is unreachable + console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr); + loaded = await loadFromBase("https://esm.sh/"); + window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/"; + } + + const { three, controls, gltf, obj, stl, ply } = loaded; cachedThreeLibs = { ...three, - OrbitControls: controls.OrbitControls, - GLTFLoader: gltf.GLTFLoader, - OBJLoader: obj.OBJLoader, - STLLoader: stl.STLLoader, - PLYLoader: ply.PLYLoader + OrbitControls: controls.OrbitControls || controls.default || controls, + GLTFLoader: gltf.GLTFLoader || gltf.default || gltf, + OBJLoader: obj?.OBJLoader || obj?.default || obj, + STLLoader: stl?.STLLoader || stl?.default || stl, + PLYLoader: ply?.PLYLoader || ply?.default || ply }; return cachedThreeLibs; } catch (err) { - console.error("Comfy Sidebar: Failed to load Three.js libraries", err); + console.error("Comfy Sidebar: Failed to load Three.js libraries:", err); return null; } } @@ -49,7 +79,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center" }); const titleText = document.createElement("span"); - titleText.textContent = baseSrc.split("/").pop().split("?")[0] || "3D Model"; + titleText.textContent = getFilenameFromUrl(baseSrc) || "3D Model"; header.appendChild(titleText); overlay.container.appendChild(header); @@ -202,21 +232,25 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () loadingSpinner.style.display = "flex"; loadingSpinner.innerHTML = ` Exporting ${format}...`; + const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/"; if (format === "OBJ") { - const { OBJExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/OBJExporter.js"); + const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`); + const OBJExporter = mod.OBJExporter || mod.default || mod; const exporter = new OBJExporter(); const result = exporter.parse(currentModel); const blob = new Blob([result], { type: "text/plain" }); downloadBlob(blob, `${baseName}.obj`); } else if (format === "GLB") { - const { GLTFExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/GLTFExporter.js"); + const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`); + const GLTFExporter = mod.GLTFExporter || mod.default || mod; const exporter = new GLTFExporter(); exporter.parse(currentModel, (gltf) => { const blob = new Blob([gltf], { type: "application/octet-stream" }); downloadBlob(blob, `${baseName}.glb`); }, (err) => { console.error(err); }, { binary: true }); } else if (format === "STL") { - const { STLExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/STLExporter.js"); + const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`); + const STLExporter = mod.STLExporter || mod.default || mod; const exporter = new STLExporter(); const result = exporter.parse(currentModel, { binary: true }); const blob = new Blob([result], { type: "application/octet-stream" }); @@ -244,11 +278,16 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () const initSceneAndLoad = async (srcUrl) => { loadingSpinner.style.display = "flex"; loadingSpinner.innerHTML = ` Loading 3D Asset...`; - titleText.textContent = srcUrl.split("/").pop().split("?")[0] || "3D Model"; + titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model"; THREE = await getThreeLibs(); if (!THREE) { - loadingSpinner.textContent = "Failed to load 3D engine."; + loadingSpinner.innerHTML = ` +
+ Failed to load 3D engine.
+ Blocked by ComfyUI Content Security Policy (CSP). Check if --disable-api-nodes is enabled in your startup arguments, or vendor Three.js locally. +
+ `; return; } @@ -450,7 +489,8 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () } try { - const ext = (srcUrl.split("?")[0].split(".").pop() || "glb").toLowerCase(); + const filename = getFilenameFromUrl(srcUrl); + const ext = (filename.split(".").pop() || "glb").toLowerCase(); let loadedObj = null; if (ext === "obj" && THREE.OBJLoader) { diff --git a/pyproject.toml b/pyproject.toml index a044b7a..5c50f8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.4.2" +version = "1.5.0" license = { file = "LICENSE" } dependencies = []