Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5074d5aec | ||
|
|
bbbbb7b686 | ||
|
|
daadd7c2ac | ||
|
|
9a7981d675 | ||
|
|
d4bc6b1849 | ||
|
|
78264c385a | ||
|
|
479ae0b73f | ||
|
|
25520c8612 | ||
|
|
949220a260 | ||
|
|
e3d943a353 | ||
|
|
929928aa97 | ||
|
|
dc868f298f | ||
|
|
cdd4d399c8 | ||
|
|
70c8e64c4e | ||
|
|
1f3c3cb598 | ||
|
|
0238cf109d | ||
|
|
869d3b9a28 | ||
|
|
f09e2c7921 | ||
|
|
9adfb2a53f | ||
|
|
a17314673e |
+1
-2
@@ -1,3 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
js/vendor/
|
||||
*.pyc
|
||||
@@ -1,68 +1,81 @@
|
||||
# ComfyUI-ComfySidebar
|
||||
|
||||
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
|
||||
|
||||
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
|
||||
A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Browse generation results in a compact grid.
|
||||
* Drag results back onto the canvas to restore workflows.
|
||||
* Drag images directly into `LoadImage` and compatible input nodes.
|
||||
* Preview images, videos, and text outputs.
|
||||
* Compare two results side by side with `Shift+Click`.
|
||||
* Download generated files or workflow JSON directly from result cards.
|
||||
* Show queue progress and the node currently being executed.
|
||||
* Hide sidebar tabs and UI elements you don't use.
|
||||
* Optionally replace the stock Job History sidebar.
|
||||
* Optional unified top bar for workflow tabs and controls.
|
||||
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
|
||||
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
|
||||
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
|
||||
* **Compact, information-dense layout** with visual previews that let you see what is happening at a glance. Optional UI elements and sidebar tabs can be hidden when you don't need them.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
<img src="assets/layout.png" alt="ComfySidebar layout" width="100%">
|
||||
|
||||
* **Left or right side:** Choose which side the sidebar appears on in ComfyUI settings.
|
||||
|
||||
* **Simplified mouse-friendly workflow** with drag-and-drop support and convenient actions for moving results back into the workflow, opening files, and managing outputs. Hold `Ctrl` for extra actions such as opening an image's location or deleting it directly from your device.
|
||||
|
||||
<img src="assets/controls.png" alt="ComfySidebar controls overview" width="100%">
|
||||
|
||||
* **Built-in comparison tool** for quickly comparing two generated results side by side.
|
||||
|
||||
<img src="assets/compare.png" alt="ComfySidebar compare" width="100%">
|
||||
|
||||
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
|
||||
|
||||
<img src="assets/runtime.png" alt="ComfySidebar runtime mode" width="100%">
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
Clone the repository directly into your ComfyUI custom nodes directory:
|
||||
### ComfyUI Registry
|
||||
|
||||
Install directly from the ComfyUI Manager or run:
|
||||
|
||||
```bash
|
||||
comfy node install comfy-sidebar
|
||||
```
|
||||
|
||||
ComfySidebar is available on the [ComfyUI Registry](https://registry.comfy.org/publishers/soundslikethunder/nodes/comfy-sidebar).
|
||||
|
||||
### Manual installation
|
||||
|
||||
Clone the repository into your ComfyUI custom nodes directory:
|
||||
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/m0rtus59/ComfyUI-ComfySidebar.git
|
||||
|
||||
```
|
||||
|
||||
Restart your ComfyUI server and refresh the browser tab.
|
||||
Restart ComfyUI and refresh the browser.
|
||||
|
||||
## ⚙️ Configuration
|
||||
## ⚙️ Settings
|
||||
|
||||
Open **Settings → Comfy Sidebar** to configure:
|
||||
|
||||
* Queue grid layout
|
||||
* Result aspect ratio
|
||||
* Queue layout and column width
|
||||
* Queue display mode
|
||||
* Working node display
|
||||
* Automatic cleanup of cancelled/failed jobs
|
||||
* Automatic cleanup of cancelled or failed jobs
|
||||
* Sidebar tabs to hide
|
||||
* Stock Job History replacement
|
||||
* Graph button visibility
|
||||
* Unified top bar layout
|
||||
* Permanent deletion behavior
|
||||
|
||||
## ⌨️ Shortcuts
|
||||
## ⌨️ Controls
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `Q` | Toggle ComfySidebar |
|
||||
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open file location/alternative action |
|
||||
| Shortcut | Action |
|
||||
| ------------- | ---------------------------------------------- |
|
||||
| `Q` | Toggle ComfySidebar |
|
||||
| `Ctrl+Q` | Exclude the selected node from sidebar results |
|
||||
| `Alt+R` | Renumber nodes in execution order |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open the file location / alternative action |
|
||||
|
||||
You can also use the available actions from result cards, the sidebar header, and the node/canvas context menus.
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
ComfySidebar integrates with the ComfyUI frontend and therefore depends on parts of its UI structure. Frontend changes in ComfyUI may require corresponding updates to this extension.
|
||||
ComfySidebar integrates with the ComfyUI frontend and depends on parts of its UI structure. Frontend changes in ComfyUI may require updates to this extension.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+34
-71
@@ -16,7 +16,12 @@ def is_path_safe(base_dir, target_path):
|
||||
"""Prevents Path Traversal attacks."""
|
||||
real_base = os.path.realpath(base_dir)
|
||||
real_target = os.path.realpath(target_path)
|
||||
return real_target == real_base or real_target.startswith(real_base + os.sep)
|
||||
try:
|
||||
# commonpath reliably handles OS-specific case sensitivity and slash logic
|
||||
return os.path.commonpath([real_base, real_target]) == real_base
|
||||
except ValueError:
|
||||
# Raised if paths are on different drives on Windows
|
||||
return False
|
||||
|
||||
|
||||
def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
@@ -55,9 +60,9 @@ def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output"):
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories."""
|
||||
candidates = get_all_candidate_roots(subfolder, folder_type)
|
||||
candidates = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
|
||||
|
||||
if filename:
|
||||
for root in candidates:
|
||||
@@ -89,10 +94,10 @@ def open_file_in_os(target_path):
|
||||
if not os.path.exists(norm_path):
|
||||
raise FileNotFoundError(f"Path not found: {norm_path}")
|
||||
if os.path.isfile(norm_path):
|
||||
# Passed as a single argument so Windows Explorer highlights the file properly
|
||||
subprocess.Popen(["explorer", f"/select,{norm_path}"])
|
||||
# Use raw command string so Windows Explorer correctly parses /select,"path"
|
||||
subprocess.Popen(f'explorer /select,"{norm_path}"')
|
||||
else:
|
||||
os.startfile(norm_path)
|
||||
subprocess.Popen(f'explorer "{norm_path}"')
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
if not os.path.exists(target_path):
|
||||
@@ -141,19 +146,34 @@ async def delete_file_handler(request):
|
||||
if not filename:
|
||||
return web.json_response({"error": "No filename provided"}, status=400)
|
||||
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
|
||||
# Strictly restrict deletion to generated output and temp files (protects models/checkpoints)
|
||||
delete_roots = [
|
||||
folder_paths.get_output_directory(),
|
||||
folder_paths.get_temp_directory()
|
||||
]
|
||||
delete_roots = [os.path.realpath(r) for r in delete_roots if r and os.path.exists(r)]
|
||||
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots)
|
||||
|
||||
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
|
||||
return web.json_response({"error": "File not found or unsafe path"}, status=404)
|
||||
return web.json_response({"error": "File not found or outside output directory"}, status=404)
|
||||
|
||||
# Try moving to Recycle Bin / Trash first, fallback to os.remove
|
||||
try:
|
||||
import send2trash
|
||||
send2trash.send2trash(target_file)
|
||||
except Exception:
|
||||
permanent = bool(data.get("permanent", False))
|
||||
action = "deleted"
|
||||
if not permanent:
|
||||
try:
|
||||
import send2trash
|
||||
send2trash.send2trash(target_file)
|
||||
action = "trashed"
|
||||
except Exception:
|
||||
os.remove(target_file)
|
||||
action = "deleted"
|
||||
else:
|
||||
os.remove(target_file)
|
||||
action = "deleted"
|
||||
|
||||
return web.json_response({"success": True, "deleted": target_file})
|
||||
return web.json_response({"success": True, "deleted": target_file, "action": action})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -180,61 +200,4 @@ 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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 789 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 631 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
+107
-30
@@ -43,15 +43,36 @@ export function findTopbarContainer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
|
||||
export function findPropertiesPanel() {
|
||||
const testIdPanel = document.querySelector('[data-testid="properties-panel"], [data-testid="workflow-overview-panel"], .properties-panel, [class*="properties-panel"]');
|
||||
if (testIdPanel && testIdPanel.offsetWidth > 0 && testIdPanel.offsetHeight > 0 && testIdPanel.isConnected) {
|
||||
return testIdPanel;
|
||||
}
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, [class*="title"]');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim().toLowerCase() === "workflow overview" && h.offsetWidth > 0 && h.offsetHeight > 0) {
|
||||
const container = h.closest('.p-sidebar, .sidebar, aside, [class*="side-panel"], [class*="panel"], [class*="properties"]') || h.parentElement;
|
||||
if (container && container.offsetWidth > 0 && container !== document.body) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rightSidebars = document.querySelectorAll('.p-sidebar-right, aside.p-sidebar, [class*="p-sidebar-right"]');
|
||||
for (const sb of rightSidebars) {
|
||||
if (sb.offsetWidth > 0 && sb.offsetHeight > 0) {
|
||||
return sb;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPropertiesPanelOpen() {
|
||||
const panel = findPropertiesPanel();
|
||||
if (panel) return true;
|
||||
|
||||
const origBtn = findOriginalPropertiesButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
@@ -64,6 +85,56 @@ export function isPropertiesPanelOpen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findActiveSidebars() {
|
||||
const leftPanels = [];
|
||||
const rightPanels = [];
|
||||
const winW = window.innerWidth;
|
||||
const midX = winW / 2;
|
||||
|
||||
// 1. Check if our Queue sidebar is open and visible
|
||||
const queueHeader = document.querySelector('.comfy-sidebar-header-root');
|
||||
if (queueHeader) {
|
||||
const queueDrawer = queueHeader.closest('.sidebar-content-container, .comfyui-sidebar-content, .p-sidebar, aside') || queueHeader.parentElement;
|
||||
if (queueDrawer && queueDrawer.offsetWidth > 0 && queueDrawer.offsetHeight > 0 && queueDrawer.isConnected) {
|
||||
const r = queueDrawer.getBoundingClientRect();
|
||||
if (r.right > 0 && r.right < midX) {
|
||||
leftPanels.push(queueDrawer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Query all active sidebars and panels across ComfyUI & third-party extensions
|
||||
const candidates = document.querySelectorAll(
|
||||
'aside, nav, .comfyui-sidebar, .side-tool-bar-container, .sidebar-content-container, ' +
|
||||
'.comfyui-sidebar-content, .p-sidebar, [data-testid="properties-panel"], ' +
|
||||
'[data-testid="workflow-overview-panel"], [data-testid*="sidebar"], .p-sidebar-right'
|
||||
);
|
||||
|
||||
const seen = new Set(leftPanels);
|
||||
|
||||
for (const el of candidates) {
|
||||
if (!el.isConnected || el.offsetWidth < 30 || el.offsetHeight < 120) continue;
|
||||
if (el.closest('.comfy-sidebar-comparison-overlay, .p-dialog, [role="dialog"], .comfy-modal, .comfy-menu')) continue;
|
||||
|
||||
if (seen.has(el)) continue;
|
||||
seen.add(el);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0 || rect.top > 150) continue;
|
||||
|
||||
// Left-docked panels (Icon bar or an open drawer on the left)
|
||||
if (rect.left <= 100 && rect.right > 0 && rect.right < midX) {
|
||||
leftPanels.push(el);
|
||||
}
|
||||
// Right-docked panels (Workflow Overview or right inspector drawers)
|
||||
else if (rect.right >= winW - 80 && rect.left > midX && rect.left < winW) {
|
||||
rightPanels.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
return { leftPanels, rightPanels };
|
||||
}
|
||||
|
||||
export function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
@@ -103,18 +174,27 @@ export function findGraphButton() {
|
||||
}
|
||||
|
||||
export function findOurSidebarButton() {
|
||||
const byTestId = document.querySelector('[data-testid="classic-comfy-sidebar-tab-button"]');
|
||||
if (byTestId) return byTestId;
|
||||
|
||||
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 byTestId = document.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"]');
|
||||
if (byTestId && !byTestId.id?.includes('classic-comfy-sidebar') && !byTestId.querySelector('.pi-images')) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h", '[class*="lucide--history"]']) {
|
||||
const icon = document.querySelector(iconSelector);
|
||||
if (icon) {
|
||||
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') || '';
|
||||
@@ -128,29 +208,26 @@ export function findStandardQueueButton() {
|
||||
}
|
||||
|
||||
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();
|
||||
const btn = findOurSidebarButton();
|
||||
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() {
|
||||
@@ -199,4 +276,4 @@ export function centerAndSelectCanvasNode(nodeId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+753
-195
File diff suppressed because it is too large
Load Diff
+14
-7
@@ -132,17 +132,24 @@ export function setupVueNodeObserver() {
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedCanvasNodes() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [];
|
||||
const raw = canvas.selected_nodes;
|
||||
if (raw instanceof Map || raw instanceof Set) {
|
||||
return Array.from(raw.values());
|
||||
}
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === "object") return Object.values(raw);
|
||||
if (canvas.current_active_node) return [canvas.current_active_node];
|
||||
return [];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
const nodes = getSelectedCanvasNodes();
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
|
||||
@@ -42,6 +42,7 @@ export function activatePrompt(state, patch = {}) {
|
||||
state.rendered = false;
|
||||
state.startTime = state.startTime || Date.now();
|
||||
state.duration = null;
|
||||
state._hasValidatedIntermediates = true; // New live prompt; files are fresh
|
||||
if (patch.workflow) state.workflow = patch.workflow;
|
||||
if (patch.activeNodeName !== undefined) state.activeNodeName = patch.activeNodeName;
|
||||
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
|
||||
@@ -58,10 +59,14 @@ export function updatePromptProgress(state, progress, activeNodeName = null) {
|
||||
}
|
||||
|
||||
export function finalizePrompt(state, targetStatus, patch = {}) {
|
||||
revokePromptBlobs(state);
|
||||
// Only revoke blobs if not cancelled; preserve the preview so the user can inspect the cancelled step
|
||||
if (targetStatus !== PromptStatus.CANCELLED && state.status !== PromptStatus.CANCELLED) {
|
||||
revokePromptBlobs(state);
|
||||
}
|
||||
state.status = targetStatus;
|
||||
state.progressText = "";
|
||||
state.rendered = false;
|
||||
state._hasValidatedIntermediates = true; // Live session generation; files are fresh
|
||||
state.endTime = patch.endTime || Date.now();
|
||||
if (state.startTime) {
|
||||
state.duration = (state.endTime - state.startTime) / 1000;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PromptStatus } from "../core/constants.js";
|
||||
import { createPromptState, activatePrompt, updatePromptProgress } from "../core/promptState.js";
|
||||
import { syncQueue, concludeRun, initSessionAndHistory, clearCancelledOrFailed } from "./queueService.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs } from "../utils/utils.js";
|
||||
import { updateActiveComparisonPreview, isRuntimePreviewEnabled, focusRuntimePreviewTarget, getActiveComparisonViewer, showFullscreenPreview } from "../utils/comparison.js";
|
||||
|
||||
export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
const onStatus = () => syncQueue();
|
||||
@@ -44,6 +45,10 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
store.setPrompt(pid, newState);
|
||||
}
|
||||
|
||||
if (isRuntimePreviewEnabled()) {
|
||||
focusRuntimePreviewTarget();
|
||||
}
|
||||
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
@@ -68,9 +73,10 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
const showWorkingNode = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
const activePid = store.ui.currentlyActivePromptId;
|
||||
|
||||
if (showWorkingNode && activePid && store.hasPrompt(activePid)) {
|
||||
if (activePid && store.hasPrompt(activePid)) {
|
||||
const prompt = store.getPrompt(activePid);
|
||||
if (nodeId) {
|
||||
prompt.activeNodeId = String(nodeId);
|
||||
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
|
||||
prompt.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
|
||||
} else {
|
||||
@@ -78,7 +84,7 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
}
|
||||
store.updatePrompt(activePid, prompt);
|
||||
|
||||
if (typeof onTargetedProgressUpdate === "function") {
|
||||
if (showWorkingNode && typeof onTargetedProgressUpdate === "function") {
|
||||
onTargetedProgressUpdate(activePid, prompt.progress, prompt.activeNodeName);
|
||||
}
|
||||
}
|
||||
@@ -90,10 +96,28 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
const prompt = activeTasks[0];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
// Clean up the previous unreferenced blob immediately to prevent browser memory leaks
|
||||
if (prompt._oldPreviewBlobUrl && prompt._oldPreviewBlobUrl !== prompt._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(prompt._oldPreviewBlobUrl); } catch (_) {}
|
||||
}
|
||||
|
||||
prompt._oldPreviewBlobUrl = prompt._previewBlobUrl;
|
||||
prompt._previewBlobUrl = newBlobUrl;
|
||||
prompt.images = [{ url: newBlobUrl }];
|
||||
store.updatePrompt(prompt.pid, prompt);
|
||||
|
||||
// Update live fullscreen preview if currently open
|
||||
updateActiveComparisonPreview(prompt.pid, newBlobUrl);
|
||||
|
||||
// If Runtime Preview mode is enabled but the overlay was closed, auto-open it
|
||||
if (isRuntimePreviewEnabled() && !getActiveComparisonViewer()) {
|
||||
showFullscreenPreview([newBlobUrl], false, prompt.pid);
|
||||
}
|
||||
|
||||
// Update the active card in the sidebar
|
||||
if (typeof onTargetedProgressUpdate === "function") {
|
||||
onTargetedProgressUpdate(prompt.pid, prompt.progress, prompt.activeNodeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,8 +126,14 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
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) {
|
||||
// Only update prompt.images if this node actually produced real output images
|
||||
const hasRealNewImgs = nodeImgs.some(img => !img.isFallback);
|
||||
if (hasRealNewImgs) {
|
||||
prompt.images = nodeImgs;
|
||||
// If fullscreen is open, smoothly transition to the final saved image
|
||||
const finalImg = nodeImgs[0];
|
||||
const finalSrc = finalImg.url || (window.location.origin + `/view?filename=${encodeURIComponent(finalImg.filename)}&type=${finalImg.type || 'output'}&subfolder=${encodeURIComponent(finalImg.subfolder || '')}&rand=${encodeURIComponent(pid)}`);
|
||||
updateActiveComparisonPreview(pid, finalSrc);
|
||||
}
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
@@ -117,8 +147,20 @@ export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
}
|
||||
};
|
||||
|
||||
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, PromptStatus.COMPLETED);
|
||||
const onExecutionError = (e) => concludeRun(e.detail.prompt_id, PromptStatus.ERROR);
|
||||
const onExecutionSuccess = (e) => {
|
||||
const pid = e.detail?.prompt_id;
|
||||
if (pid) concludeRun(pid, PromptStatus.COMPLETED);
|
||||
};
|
||||
const onExecutionError = (e) => {
|
||||
const pid = e.detail?.prompt_id;
|
||||
const nodeId = e.detail?.node_id;
|
||||
if (pid && nodeId && store.hasPrompt(String(pid))) {
|
||||
const prompt = store.getPrompt(String(pid));
|
||||
prompt.activeNodeId = String(nodeId);
|
||||
store.updatePrompt(String(pid), prompt);
|
||||
}
|
||||
if (pid) concludeRun(pid, PromptStatus.ERROR);
|
||||
};
|
||||
const onExecutionInterrupted = () => {
|
||||
store.getAllPrompts()
|
||||
.filter(t => t.status === PromptStatus.ACTIVE)
|
||||
|
||||
@@ -104,7 +104,8 @@ export async function syncQueue() {
|
||||
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);
|
||||
// Do not prematurely mark as CANCELLED; let concludeRun verify via /history
|
||||
concludeRun(prompt.pid, PromptStatus.COMPLETED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +129,10 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
|
||||
const state = store.getPrompt(key);
|
||||
finalizePrompt(state, fallbackStatus);
|
||||
|
||||
// If cancelled while actively sampling, preserve the exact step preview where it was cancelled
|
||||
const isCancelled = fallbackStatus === PromptStatus.CANCELLED || state.status === PromptStatus.CANCELLED;
|
||||
const hasActivePreviewBlob = Boolean(state._previewBlobUrl || state.images?.some(img => img.url?.startsWith("blob:")));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
@@ -144,7 +149,14 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
|
||||
Object.assign(state.nodeTitles, titles);
|
||||
|
||||
const primaryImgs = getPrimaryOutputImages(norm.outputs, state.workflow);
|
||||
if (primaryImgs.length > 0) state.images = primaryImgs;
|
||||
const hasActualSavedOutputs = primaryImgs.some(img => !img.isFallback);
|
||||
|
||||
// If cancelled while sampling, do NOT overwrite with older nodes; keep the exact step preview!
|
||||
if (!isCancelled || !hasActivePreviewBlob) {
|
||||
if (hasActualSavedOutputs || (state.status === PromptStatus.COMPLETED && primaryImgs.length > 0)) {
|
||||
state.images = primaryImgs;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(norm.outputs, state.workflow);
|
||||
if (fetchedTexts.length > 0) state.texts = fetchedTexts;
|
||||
@@ -154,6 +166,13 @@ export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED)
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
// Ensure cancelled prompt keeps its preview blob if it had one
|
||||
if (isCancelled && hasActivePreviewBlob) {
|
||||
if (state._previewBlobUrl && (!state.images || state.images.length === 0 || !state.images[0].url?.startsWith("blob:"))) {
|
||||
state.images = [{ url: state._previewBlobUrl }];
|
||||
}
|
||||
}
|
||||
|
||||
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
store.pruneHistory(maxItems);
|
||||
store.updatePrompt(key, state);
|
||||
|
||||
+69
-2
@@ -2,6 +2,7 @@ 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 { renumberNodesTopologically, setManualNodeId } from "./utils/nodeRenumber.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";
|
||||
@@ -9,6 +10,7 @@ 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";
|
||||
import { store } from "./core/store.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
@@ -42,6 +44,12 @@ function registerSettings() {
|
||||
type: "boolean",
|
||||
defaultValue: false
|
||||
});
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
|
||||
name: "Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)",
|
||||
type: "boolean",
|
||||
defaultValue: false
|
||||
});
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
@@ -79,6 +87,27 @@ function registerSettings() {
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Avatar",
|
||||
name: "Hide Avatar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Floating Canvas Controls",
|
||||
name: "Floating Canvas Controls",
|
||||
type: "combo",
|
||||
options: ["Default", "Dock", "Hide"],
|
||||
defaultValue: "Default",
|
||||
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",
|
||||
@@ -114,6 +143,13 @@ function setupKeydownShortcuts() {
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode(() => renderSidebar());
|
||||
}
|
||||
|
||||
// Alt+R: Renumber all nodes in execution order
|
||||
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
renumberNodesTopologically();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
@@ -139,9 +175,39 @@ app.registerExtension({
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode(() => renderSidebar())
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.RenumberNodes",
|
||||
label: "Renumber All Nodes (Execution Order)",
|
||||
function: () => renumberNodesTopologically()
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.SetNodeId",
|
||||
label: "Set Selected Node ID...",
|
||||
function: () => setManualNodeId()
|
||||
}
|
||||
],
|
||||
|
||||
getNodeMenuItems(node) {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
content: "Set Node ID...",
|
||||
callback: () => setManualNodeId(node)
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
getCanvasMenuItems() {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
content: "Renumber All Nodes (Execution Order)",
|
||||
callback: () => renumberNodesTopologically()
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
@@ -178,9 +244,10 @@ app.registerExtension({
|
||||
// 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 state = store.getPrompt(pid);
|
||||
if (cardObj && state) {
|
||||
const showWorkingNode = app.ui.settings.getSettingValue(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
|
||||
updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode);
|
||||
updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode, state);
|
||||
} else {
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isAudioViewerOpen as checkAudioViewerOpen } from "../utils/comparison.js";
|
||||
|
||||
let currentlyPlayingAudio = null;
|
||||
let activeComparisonViewer = null;
|
||||
|
||||
export function stopAllAudioPlayback() {
|
||||
if (currentlyPlayingAudio) {
|
||||
@@ -21,14 +22,6 @@ export function getCurrentlyPlayingAudio() {
|
||||
return currentlyPlayingAudio;
|
||||
}
|
||||
|
||||
export function setActiveComparisonViewer(viewer) {
|
||||
activeComparisonViewer = viewer;
|
||||
}
|
||||
|
||||
export function getActiveComparisonViewer() {
|
||||
return activeComparisonViewer;
|
||||
}
|
||||
|
||||
export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
return checkAudioViewerOpen();
|
||||
}
|
||||
|
||||
+228
-44
@@ -2,12 +2,13 @@ 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 { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage, removeImageFromNodeOutputs } 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";
|
||||
import { showNotification } from "../utils/nodeRenumber.js";
|
||||
|
||||
export const cardPool = new Map();
|
||||
|
||||
@@ -31,9 +32,11 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
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 randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
|
||||
// Check if the card has an actual verified file saved on disk
|
||||
const hasRealDiskFiles = state.images && state.images.length > 0 &&
|
||||
state.images.some(img => img.filename && !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;
|
||||
@@ -47,7 +50,7 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
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.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
});
|
||||
@@ -64,7 +67,7 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
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 src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
@@ -88,7 +91,7 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
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 || '')}`;
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
|
||||
wf = await extractWorkflowFromPng(src);
|
||||
if (wf) state.workflow = wf;
|
||||
}
|
||||
@@ -110,26 +113,24 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
}
|
||||
|
||||
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;
|
||||
cardObj.btnDel.style.removeProperty("display");
|
||||
cardObj.btnDel.style.display = "inline-flex";
|
||||
|
||||
cardObj.canDeleteFromDisk = hasRealDiskFiles;
|
||||
if (cardObj.btnDelLabel) {
|
||||
cardObj.btnDelLabel.style.display = hasRealDiskFiles ? "" : "none";
|
||||
}
|
||||
cardObj.btnDel.title = hasRealDiskFiles
|
||||
? "Delete Card (Hold Ctrl to delete file from disk)"
|
||||
: "Delete Card";
|
||||
}
|
||||
|
||||
if (cardObj.btnFocus) {
|
||||
const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null;
|
||||
const nodeId = (currentImg ? findNodeIdForImage(state, currentImg) : null) || state.activeNodeId;
|
||||
if (nodeId) {
|
||||
cardObj.btnFocus.style.removeProperty("display");
|
||||
cardObj.btnFocus.style.display = "inline-flex";
|
||||
cardObj.btnFocus.title = (!currentImg && state.activeNodeId) ? "Show Stopped/Failed Node" : "Show Node";
|
||||
cardObj.btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
centerAndSelectCanvasNode(nodeId);
|
||||
@@ -141,40 +142,180 @@ export function syncCardButtonVisibility(cardObj, state) {
|
||||
|
||||
if (cardObj.leftHoverBtn) {
|
||||
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
|
||||
const validOutputs = outputs.filter(o => o.images && o.images.length > 0);
|
||||
const validOutputs = outputs
|
||||
.map(o => ({
|
||||
...o,
|
||||
images: (o.images || []).filter(img => img && img.filename && !img.isFallback)
|
||||
}))
|
||||
.filter(o => o.images.length > 0);
|
||||
|
||||
const primarySignatures = new Set((state.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
|
||||
const distinctOutputs = [];
|
||||
|
||||
for (const out of validOutputs) {
|
||||
const hasNewImage = out.images.some(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
|
||||
if (hasNewImage) {
|
||||
distinctOutputs.push(out);
|
||||
const distinctImages = out.images.filter(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
|
||||
if (distinctImages.length > 0) {
|
||||
distinctOutputs.push({ ...out, images: distinctImages });
|
||||
}
|
||||
}
|
||||
|
||||
const hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0;
|
||||
const hasIntermediates = (validOutputs.length > 1 && distinctOutputs.length > 0) ||
|
||||
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
|
||||
|
||||
if (hasIntermediates) {
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
cardObj.leftHoverBtn.onclick = (ev) => {
|
||||
if (!hasIntermediates) {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
// If already verified (or freshly generated), display button immediately
|
||||
if (state._hasValidatedIntermediates) {
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
} else {
|
||||
// Old restored card: keep hidden by default while stealthily testing files
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
|
||||
if (!cardObj._isCheckingIntermediates) {
|
||||
cardObj._isCheckingIntermediates = true;
|
||||
|
||||
const checkPromises = [];
|
||||
const missingImages = [];
|
||||
|
||||
for (const out of distinctOutputs) {
|
||||
for (const img of out.images) {
|
||||
if (!img.filename || (img.url && img.url.startsWith("blob:"))) continue;
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
checkPromises.push(
|
||||
fetch(src, { method: "HEAD" })
|
||||
.then(res => {
|
||||
if (res.status === 404) {
|
||||
missingImages.push(img);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
missingImages.push(img);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalizeCheck = () => {
|
||||
cardObj._isCheckingIntermediates = false;
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
liveState._hasValidatedIntermediates = true;
|
||||
|
||||
if (missingImages.length > 0 && liveState && liveState.nodeOutputs) {
|
||||
for (const img of missingImages) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
}
|
||||
for (const nodeId in liveState.nodeOutputs) {
|
||||
const nodeVal = liveState.nodeOutputs[nodeId];
|
||||
if (nodeVal && typeof nodeVal === "object") {
|
||||
const hasAny = Object.values(nodeVal).some(v => Array.isArray(v) ? v.length > 0 : Boolean(v));
|
||||
if (!hasAny) {
|
||||
delete liveState.nodeOutputs[nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
}
|
||||
|
||||
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
const curValid = curOutputs
|
||||
.map(o => ({
|
||||
...o,
|
||||
images: (o.images || []).filter(i => i && i.filename && !i.isFallback)
|
||||
}))
|
||||
.filter(o => o.images.length > 0);
|
||||
const curDistinct = curValid.filter(o => o.images.some(i => !primarySignatures.has(`${i.subfolder || ""}/${i.filename}`)));
|
||||
const stillHas = (curValid.length > 1 && curDistinct.length > 0) ||
|
||||
(liveState.status !== PromptStatus.COMPLETED && curValid.length > 0);
|
||||
|
||||
if (stillHas) {
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
} else {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
};
|
||||
|
||||
if (checkPromises.length > 0) {
|
||||
Promise.all(checkPromises).then(finalizeCheck).catch(finalizeCheck);
|
||||
} else {
|
||||
finalizeCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cardObj.leftHoverBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
const curValid = curOutputs
|
||||
.map(o => ({
|
||||
...o,
|
||||
images: (o.images || []).filter(i => i && i.filename && !i.isFallback)
|
||||
}))
|
||||
.filter(o => o.images.length > 0);
|
||||
|
||||
const primSigs = new Set((liveState.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
|
||||
const curDistinct = curValid.filter(o => o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
|
||||
if (curDistinct.length === 0) {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
}
|
||||
|
||||
let anyMissing = false;
|
||||
for (const out of curDistinct) {
|
||||
for (const img of out.images) {
|
||||
if (primSigs.has(`${img.subfolder || ""}/${img.filename}`)) continue;
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
if (!src.startsWith("blob:")) {
|
||||
try {
|
||||
const res = await fetch(src, { method: "HEAD" });
|
||||
if (res.status === 404) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
anyMissing = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyMissing) {
|
||||
for (const nodeId in liveState.nodeOutputs) {
|
||||
const nodeVal = liveState.nodeOutputs[nodeId];
|
||||
if (nodeVal && typeof nodeVal === "object") {
|
||||
const hasAny = Object.values(nodeVal).some(v => Array.isArray(v) ? v.length > 0 : Boolean(v));
|
||||
if (!hasAny) {
|
||||
delete liveState.nodeOutputs[nodeId];
|
||||
}
|
||||
}
|
||||
}
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
const remainingOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
const remaining = remainingOutputs
|
||||
.map(o => ({ ...o, images: (o.images || []).filter(i => i && i.filename && !i.isFallback) }))
|
||||
.filter(o => o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
if (remaining.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
store.openOutputsSubmenu(liveState.pid);
|
||||
};
|
||||
} else {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true) {
|
||||
export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true, state = null) {
|
||||
if (!cardObj) return;
|
||||
if (cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${progress}%`;
|
||||
@@ -184,6 +325,20 @@ export function updateCardProgressTargeted(cardObj, progress, activeNodeName, sh
|
||||
const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? "Finishing..." : `[${activeNodeName}]`) : "Processing...";
|
||||
cardObj.statusText.textContent = `${nodeText} ${progress}%`;
|
||||
}
|
||||
|
||||
if (state && state.images && state.images.length > 0) {
|
||||
const firstImg = state.images[0];
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}` : null);
|
||||
if (previewUrl && cardObj.firstImgElement && cardObj.firstImgElement.tagName === "IMG") {
|
||||
const currentSrc = cardObj.firstImgElement.getAttribute("src") || cardObj.firstImgElement.src;
|
||||
if (currentSrc !== previewUrl && !cardObj.firstImgElement.src.endsWith(previewUrl)) {
|
||||
cardObj.firstImgElement.src = previewUrl;
|
||||
}
|
||||
} else {
|
||||
updateCardDOM(cardObj, state, true, showWorkingNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrCreateCard(state, callbacks = {}) {
|
||||
@@ -201,14 +356,15 @@ export function getOrCreateCard(state, callbacks = {}) {
|
||||
const cancelX = document.createElement("span");
|
||||
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
|
||||
Object.assign(cancelX.style, {
|
||||
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "10"
|
||||
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "25"
|
||||
});
|
||||
|
||||
const sBadge = document.createElement("div");
|
||||
sBadge.className = "comfy-sidebar-status-badge";
|
||||
Object.assign(sBadge.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold",
|
||||
padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none",
|
||||
pointerEvents: "none", zIndex: "10"
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px", fontWeight: "bold",
|
||||
padding: "3px 7px", borderRadius: "3px", textTransform: "uppercase", display: "none",
|
||||
pointerEvents: "none", zIndex: "30", letterSpacing: "0.5px", boxShadow: "0 2px 6px rgba(0,0,0,0.6)"
|
||||
});
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
@@ -288,7 +444,8 @@ export function getOrCreateCard(state, callbacks = {}) {
|
||||
hoverPanel.append(btnImg, btnJson, btnDel);
|
||||
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
|
||||
|
||||
card.append(timerEl, cancelX, sBadge, dimEl, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
|
||||
// Appending grid FIRST ensures overlays (badges, cancel button) naturally render on top
|
||||
card.append(grid, p, pt, statusText, timerEl, cancelX, dimEl, sBadge, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = {
|
||||
element: card,
|
||||
@@ -313,11 +470,11 @@ export function getOrCreateCard(state, callbacks = {}) {
|
||||
statusText,
|
||||
firstImgElement: null,
|
||||
lastImagesSignature: "",
|
||||
currentImageIndex: 0
|
||||
currentImageIndex: 0,
|
||||
_isCheckingIntermediates: false
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
// Only run automatic button visibility sync on main queue cards (avoid synthetic submenu IDs)
|
||||
if (store.hasPrompt(state.pid)) {
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
@@ -389,8 +546,21 @@ export function getOrCreateCard(state, callbacks = {}) {
|
||||
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
allOutputs.forEach(out => (out.images || []).forEach(addFile));
|
||||
|
||||
let deletedCount = 0;
|
||||
let lastAction = "trashed";
|
||||
for (const fileItem of filesToDelete) {
|
||||
await deleteFileOnServer(fileItem);
|
||||
const action = await deleteFileOnServer(fileItem);
|
||||
if (action) {
|
||||
deletedCount++;
|
||||
lastAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
const msg = lastAction === "trashed"
|
||||
? `Moved ${deletedCount} file${deletedCount > 1 ? "s" : ""} to Recycle Bin.`
|
||||
: `Permanently deleted ${deletedCount} file${deletedCount > 1 ? "s" : ""}.`;
|
||||
showNotification(msg, "success", "Delete Files");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,23 +594,37 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.CANCELLED) {
|
||||
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" });
|
||||
Object.assign(cardObj.statusBadge.style, {
|
||||
display: "block",
|
||||
background: "#eab308",
|
||||
color: "#000",
|
||||
zIndex: "30"
|
||||
});
|
||||
cardObj.statusBadge.textContent = "Cancelled";
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
} else if (state.status === PromptStatus.ERROR) {
|
||||
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" });
|
||||
Object.assign(cardObj.statusBadge.style, {
|
||||
display: "block",
|
||||
background: "#dc3545",
|
||||
color: "#fff",
|
||||
zIndex: "30"
|
||||
});
|
||||
cardObj.statusBadge.textContent = "Error";
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
} else {
|
||||
cardObj.statusBadge.style.display = "none";
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.PENDING && !showPendingSummary) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.title = "Cancel Queued Task";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
cancelPendingTask(state.pid);
|
||||
};
|
||||
} else if (state.status === PromptStatus.ACTIVE) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.title = "Interrupt Execution";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
interruptActive();
|
||||
@@ -486,7 +670,7 @@ export function updateCardDOM(cardObj, state, showPendingSummary = true, showWor
|
||||
cardObj.placeholder.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }], false, state.pid);
|
||||
};
|
||||
} else {
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs";
|
||||
|
||||
+12
-4
@@ -24,6 +24,8 @@ export async function copyImageToClipboard(src) {
|
||||
}
|
||||
}
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export async function openFileOrFolder(img) {
|
||||
if (!img) return;
|
||||
|
||||
@@ -51,7 +53,8 @@ export async function openFileOrFolder(img) {
|
||||
}
|
||||
|
||||
export async function deleteFileOnServer(fileItem) {
|
||||
if (!fileItem || !fileItem.filename) return false;
|
||||
if (!fileItem || !fileItem.filename) return null;
|
||||
const isPermanent = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Permanent Delete on Disk Deletion") ?? false;
|
||||
try {
|
||||
const res = await fetch("/comfy-sidebar/delete-file", {
|
||||
method: "POST",
|
||||
@@ -59,13 +62,18 @@ export async function deleteFileOnServer(fileItem) {
|
||||
body: JSON.stringify({
|
||||
filename: fileItem.filename,
|
||||
subfolder: fileItem.subfolder || "",
|
||||
type: fileItem.type || "output"
|
||||
type: fileItem.type || "output",
|
||||
permanent: isPermanent
|
||||
})
|
||||
});
|
||||
return res.ok;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.action || (isPermanent ? "deleted" : "trashed");
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to delete file on server", e);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+225
-34
@@ -1,18 +1,35 @@
|
||||
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js";
|
||||
import { openFileOrFolder } from "./mediaActions.js";
|
||||
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, findImagesInOutputs } from "../utils/utils.js";
|
||||
import { openFileOrFolder, removeImageFromNodeOutputs } from "./mediaActions.js";
|
||||
import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js";
|
||||
import { showFullscreenPreview } from "../utils/comparison.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
|
||||
export function findWorkflowBackgroundThumbnail(state, randParam = "") {
|
||||
if (!state) return null;
|
||||
// 1. Check if state.images already has a valid image asset
|
||||
const imgs = (state.images || []).filter(i => isImageFormat(i.filename || i.url) && !i.isFallback);
|
||||
let lastImg = imgs.length > 0 ? imgs[imgs.length - 1] : null;
|
||||
|
||||
// 2. Search all workflow node outputs for any generated image
|
||||
if (!lastImg && state.nodeOutputs) {
|
||||
const allOutputs = findImagesInOutputs(state.nodeOutputs, state.workflow);
|
||||
const validImgs = allOutputs.filter(i => isImageFormat(i.filename || i.url) && !i.isFallback);
|
||||
if (validImgs.length > 0) lastImg = validImgs[validImgs.length - 1];
|
||||
}
|
||||
|
||||
if (lastImg) {
|
||||
return lastImg.url || (window.location.origin + `/view?filename=${encodeURIComponent(lastImg.filename)}&type=${lastImg.type || 'output'}&subfolder=${encodeURIComponent(lastImg.subfolder || '')}${randParam}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
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;
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const bgImgSrc = findWorkflowBackgroundThumbnail(state, randParam);
|
||||
|
||||
if (!preview3D) {
|
||||
wrapper.innerHTML = "";
|
||||
@@ -55,6 +72,8 @@ export function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.insertBefore(newBgImg, preview3D.firstChild);
|
||||
} else if (bgImg && !bgImgSrc) {
|
||||
bgImg.remove();
|
||||
}
|
||||
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
|
||||
if (title) title.textContent = img.filename || "3D Model";
|
||||
@@ -68,7 +87,7 @@ export function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey, state?.pid);
|
||||
};
|
||||
|
||||
preview3D.setAttribute("draggable", "true");
|
||||
@@ -90,10 +109,13 @@ export function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
}
|
||||
|
||||
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
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 randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
|
||||
const ext = filename.split('.').pop().toUpperCase();
|
||||
const bgImgSrc = findWorkflowBackgroundThumbnail(state, randParam);
|
||||
|
||||
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
||||
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
||||
@@ -103,8 +125,16 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
previewAudio = document.createElement("div");
|
||||
previewAudio.className = "comfy-sidebar-audio-wrapper";
|
||||
|
||||
if (bgImgSrc) {
|
||||
const bgImg = document.createElement("img");
|
||||
bgImg.className = "comfy-sidebar-card-bg-img";
|
||||
bgImg.src = bgImgSrc;
|
||||
bgImg.alt = "Audio Cover Preview";
|
||||
previewAudio.appendChild(bgImg);
|
||||
}
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px", position: "relative", zIndex: "2" });
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-audio-title";
|
||||
@@ -117,7 +147,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
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" });
|
||||
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0", zIndex: "2" });
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
@@ -144,7 +174,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
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" });
|
||||
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box", position: "relative", zIndex: "2" });
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
Object.assign(scrubber.style, {
|
||||
@@ -202,7 +232,7 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
|
||||
if (isAudioViewerOpen()) {
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
showFullscreenPreview([fullUrl], false, state?.pid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,8 +279,23 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([fullUrl]);
|
||||
showFullscreenPreview([fullUrl], false, state?.pid);
|
||||
};
|
||||
} else {
|
||||
const bgImg = previewAudio.querySelector(".comfy-sidebar-card-bg-img");
|
||||
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
|
||||
bgImg.src = bgImgSrc;
|
||||
} else if (!bgImg && bgImgSrc) {
|
||||
const newBgImg = document.createElement("img");
|
||||
newBgImg.className = "comfy-sidebar-card-bg-img";
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "Audio Cover Preview";
|
||||
previewAudio.insertBefore(newBgImg, previewAudio.firstChild);
|
||||
} else if (bgImg && !bgImgSrc) {
|
||||
bgImg.remove();
|
||||
}
|
||||
const title = previewAudio.querySelector(".comfy-sidebar-audio-title");
|
||||
if (title) title.textContent = filename;
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = previewAudio;
|
||||
@@ -272,19 +317,32 @@ export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
}
|
||||
|
||||
export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
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 randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
|
||||
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
|
||||
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
|
||||
const bgImgSrc = findWorkflowBackgroundThumbnail(state, randParam);
|
||||
|
||||
if (!previewFile) {
|
||||
wrapper.innerHTML = "";
|
||||
previewFile = document.createElement("div");
|
||||
previewFile.className = "comfy-sidebar-file-wrapper";
|
||||
|
||||
if (bgImgSrc) {
|
||||
const bgImg = document.createElement("img");
|
||||
bgImg.className = "comfy-sidebar-card-bg-img";
|
||||
bgImg.src = bgImgSrc;
|
||||
bgImg.alt = "File Thumbnail Preview";
|
||||
previewFile.appendChild(bgImg);
|
||||
}
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" });
|
||||
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px", position: "relative", zIndex: "2" });
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-file-badge";
|
||||
@@ -292,7 +350,7 @@ export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state)
|
||||
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" });
|
||||
Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0", position: "relative", zIndex: "2" });
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-file comfy-sidebar-file-icon";
|
||||
@@ -305,7 +363,7 @@ export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state)
|
||||
centerArea.append(icon, title);
|
||||
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
|
||||
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", position: "relative", zIndex: "2" });
|
||||
|
||||
const folderInfo = document.createElement("div");
|
||||
folderInfo.className = "comfy-sidebar-file-subtext";
|
||||
@@ -320,6 +378,18 @@ export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state)
|
||||
previewFile.append(topRow, centerArea, bottomRow);
|
||||
wrapper.appendChild(previewFile);
|
||||
} else {
|
||||
const bgImg = previewFile.querySelector(".comfy-sidebar-card-bg-img");
|
||||
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
|
||||
bgImg.src = bgImgSrc;
|
||||
} else if (!bgImg && bgImgSrc) {
|
||||
const newBgImg = document.createElement("img");
|
||||
newBgImg.className = "comfy-sidebar-card-bg-img";
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "File Thumbnail Preview";
|
||||
previewFile.insertBefore(newBgImg, previewFile.firstChild);
|
||||
} else if (bgImg && !bgImgSrc) {
|
||||
bgImg.remove();
|
||||
}
|
||||
const title = previewFile.querySelector(".comfy-sidebar-file-title");
|
||||
if (title) { title.textContent = filename; title.title = filename; }
|
||||
const badge = previewFile.querySelector(".comfy-sidebar-file-badge");
|
||||
@@ -364,7 +434,8 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const isVideo = isVideoFormat(src) || isVideoFormat(img.filename);
|
||||
const is3D = is3DFormat(src) || is3DFormat(img.filename);
|
||||
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
|
||||
@@ -387,22 +458,57 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
wrapper.appendChild(ctrlOverlay);
|
||||
}
|
||||
|
||||
// Direct children check: safely disconnect any loading event listeners on previous media
|
||||
const existingTopImg = wrapper.querySelector(":scope > img, :scope > video");
|
||||
const existing3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
|
||||
const existingAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
|
||||
const existingFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
|
||||
|
||||
if (existingTopImg) {
|
||||
if (existingTopImg.tagName === "VIDEO") {
|
||||
existingTopImg.onloadedmetadata = null;
|
||||
existingTopImg.pause();
|
||||
existingTopImg.src = "";
|
||||
existingTopImg.load();
|
||||
} else {
|
||||
existingTopImg.onload = null;
|
||||
existingTopImg.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (is3D) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
if (existingTopImg) existingTopImg.remove();
|
||||
if (existingAudio) existingAudio.remove();
|
||||
if (existingFile) existingFile.remove();
|
||||
render3DCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
if (existingTopImg) existingTopImg.remove();
|
||||
if (existing3D) existing3D.remove();
|
||||
if (existingFile) existingFile.remove();
|
||||
renderAudioCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
if (existingTopImg) existingTopImg.remove();
|
||||
if (existing3D) existing3D.remove();
|
||||
if (existingAudio) existingAudio.remove();
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaEl = wrapper.querySelector("img, video");
|
||||
// Standard Image/Video mode: remove any 3D, audio, or file preview boxes
|
||||
if (existing3D) existing3D.remove();
|
||||
if (existingAudio) existingAudio.remove();
|
||||
if (existingFile) existingFile.remove();
|
||||
|
||||
let mediaEl = wrapper.querySelector(":scope > img, :scope > video");
|
||||
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
if (needsRebuild) {
|
||||
@@ -427,25 +533,110 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([src], ev.shiftKey);
|
||||
let activeSrc = mediaEl.currentSrc || mediaEl.src || src;
|
||||
if (activeSrc && activeSrc.startsWith("blob:") && mediaEl.naturalWidth > 0) {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = mediaEl.naturalWidth;
|
||||
canvas.height = mediaEl.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
|
||||
activeSrc = canvas.toDataURL("image/png");
|
||||
} catch (_) {}
|
||||
}
|
||||
showFullscreenPreview([activeSrc], ev.shiftKey, state?.pid);
|
||||
};
|
||||
|
||||
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;
|
||||
mediaEl.onerror = () => {
|
||||
mediaEl.onerror = null;
|
||||
if ((src && src.startsWith("blob:")) || is3D || isAudio) return;
|
||||
|
||||
const cardId = cardObj.element?.id || "";
|
||||
|
||||
// 1. If an intermediate output item is missing, remove only this item
|
||||
if (cardId.startsWith("card-submenu-")) {
|
||||
const parentState = store.getPrompt(state.pid);
|
||||
if (parentState && parentState.nodeOutputs) {
|
||||
if (state.nodeId && parentState.nodeOutputs[state.nodeId]) {
|
||||
delete parentState.nodeOutputs[state.nodeId];
|
||||
} else {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
} catch (e) {}
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
store.updatePrompt(parentState.pid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If a batch item is missing, remove only this item from the batch
|
||||
if (cardId.startsWith("card-batch-")) {
|
||||
const parentPid = state.parentPromptId || state.pid;
|
||||
const parentState = store.getPrompt(parentPid);
|
||||
if (parentState) {
|
||||
if (Array.isArray(parentState.images)) {
|
||||
const idx = parentState.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
|
||||
if (idx > -1) parentState.images.splice(idx, 1);
|
||||
}
|
||||
if (parentState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
store.updatePrompt(parentPid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Main card: if this file is gone, clean it up
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
if (liveState) {
|
||||
if (Array.isArray(liveState.images)) {
|
||||
liveState.images = liveState.images.filter(i =>
|
||||
!(i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""))
|
||||
);
|
||||
}
|
||||
if (liveState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
}
|
||||
|
||||
if (liveState.images && liveState.images.length > 0) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
store.deletePrompt(liveState.pid);
|
||||
cardObj.element?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
// Strictly forbid dimension badges on 3D, Audio, or File cards
|
||||
if (is3D || isAudio || (!isImage && !isVideo)) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (cardObj.dimEl && width && height) {
|
||||
if (state.status === "active" || state.status === "cancelled" || state.status === "error") {
|
||||
cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
cardObj.dimEl.textContent = `${width}x${height}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
@@ -620,4 +811,4 @@ export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
} else {
|
||||
if (navBar) navBar.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
-39
@@ -7,11 +7,87 @@ import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.
|
||||
import { matchesFilter, getRunOutputs, isImageFormat } from "../utils/utils.js";
|
||||
import { renderCardImages } from "./mediaPreview.js";
|
||||
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
|
||||
import { toggleIgnoreActiveNode } from "../comfy/nodes.js";
|
||||
import { renumberNodesTopologically, showNotification } from "../utils/nodeRenumber.js";
|
||||
|
||||
let scrollToTopBtnEl = null;
|
||||
let globalClickRegistered = false;
|
||||
let renderAnimationFrameId = null;
|
||||
let activeTimerInterval = null;
|
||||
let currentColumnCount = 1;
|
||||
|
||||
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
|
||||
if (!cardStack) return;
|
||||
|
||||
// 1. Full-width container (for banners like Pending Queue summary)
|
||||
let bannerContainer = cardStack.querySelector('.comfy-sidebar-banners');
|
||||
if (!bannerContainer) {
|
||||
bannerContainer = document.createElement('div');
|
||||
bannerContainer.className = 'comfy-sidebar-banners';
|
||||
Object.assign(bannerContainer.style, { display: 'flex', flexDirection: 'column', gap: '12px', width: '100%' });
|
||||
cardStack.insertBefore(bannerContainer, cardStack.firstChild);
|
||||
}
|
||||
fullWidthEls.forEach((el, i) => {
|
||||
if (bannerContainer.children[i] !== el) {
|
||||
bannerContainer.insertBefore(el, bannerContainer.children[i] || null);
|
||||
}
|
||||
});
|
||||
while (bannerContainer.children.length > fullWidthEls.length) {
|
||||
bannerContainer.removeChild(bannerContainer.lastChild);
|
||||
}
|
||||
bannerContainer.style.display = fullWidthEls.length > 0 ? 'flex' : 'none';
|
||||
bannerContainer.style.marginBottom = fullWidthEls.length > 0 ? '12px' : '0';
|
||||
|
||||
// 2. Masonry Columns container
|
||||
let columnsWrapper = cardStack.querySelector('.comfy-sidebar-masonry-wrapper');
|
||||
if (!columnsWrapper) {
|
||||
columnsWrapper = document.createElement('div');
|
||||
columnsWrapper.className = 'comfy-sidebar-masonry-wrapper';
|
||||
Object.assign(columnsWrapper.style, { display: 'flex', gap: '12px', alignItems: 'flex-start', width: '100%' });
|
||||
cardStack.appendChild(columnsWrapper);
|
||||
}
|
||||
|
||||
while (columnsWrapper.children.length < cols) {
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'comfy-sidebar-masonry-col';
|
||||
Object.assign(colDiv.style, { flex: '1', display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '0' });
|
||||
columnsWrapper.appendChild(colDiv);
|
||||
}
|
||||
while (columnsWrapper.children.length > cols) {
|
||||
columnsWrapper.removeChild(columnsWrapper.lastChild);
|
||||
}
|
||||
|
||||
// Distribute cards into the shortest column so short cards stack next to tall cards
|
||||
const colBuckets = Array.from({ length: cols }, () => []);
|
||||
const colHeights = new Array(cols).fill(0);
|
||||
|
||||
cardEls.forEach((cardEl) => {
|
||||
let shortestCol = 0;
|
||||
for (let c = 1; c < cols; c++) {
|
||||
if (colHeights[c] < colHeights[shortestCol]) {
|
||||
shortestCol = c;
|
||||
}
|
||||
}
|
||||
colBuckets[shortestCol].push(cardEl);
|
||||
|
||||
const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : (cardEl._cachedHeight || 160);
|
||||
cardEl._cachedHeight = cardH;
|
||||
colHeights[shortestCol] += cardH + 12;
|
||||
});
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const colDiv = columnsWrapper.children[c];
|
||||
const bucket = colBuckets[c];
|
||||
bucket.forEach((el, i) => {
|
||||
if (colDiv.children[i] !== el) {
|
||||
colDiv.insertBefore(el, colDiv.children[i] || null);
|
||||
}
|
||||
});
|
||||
while (colDiv.children.length > bucket.length) {
|
||||
colDiv.removeChild(colDiv.lastChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getScrollContainer() {
|
||||
if (!store.ui.cardStack) return null;
|
||||
@@ -112,7 +188,20 @@ export function setupSidebarView() {
|
||||
standardHeader.appendChild(titleGroup);
|
||||
|
||||
const actionsGroup = document.createElement("div");
|
||||
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
|
||||
Object.assign(actionsGroup.style, { display: "flex", gap: "5px", alignItems: "center" });
|
||||
|
||||
const createInstantBtn = (iconClass, tooltip, onClickFn) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `${iconClass} comfy-sidebar-header-btn`;
|
||||
btn.title = tooltip;
|
||||
btn.addEventListener("click", async (ev) => {
|
||||
ev.stopPropagation();
|
||||
btn.style.transform = "scale(0.88)";
|
||||
setTimeout(() => { btn.style.transform = ""; }, 120);
|
||||
await onClickFn();
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
|
||||
const btn = document.createElement("button");
|
||||
@@ -146,6 +235,31 @@ export function setupSidebarView() {
|
||||
return btn;
|
||||
};
|
||||
|
||||
const btnToggleIgnore = createInstantBtn("pi pi-eye-slash", "Toggle Ignore Selected Node(s) in Queue (Ctrl+Q)", () => {
|
||||
const raw = app.canvas?.selected_nodes;
|
||||
const selected = raw instanceof Map || raw instanceof Set
|
||||
? Array.from(raw.values())
|
||||
: (Array.isArray(raw) ? raw : (raw && typeof raw === "object" ? Object.values(raw) : []));
|
||||
const selectedNodes = selected.length > 0 ? selected : (app.canvas?.selected_node ? [app.canvas.selected_node] : []);
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
showNotification("No nodes selected. Click a node on the canvas first to toggle ignore.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
toggleIgnoreActiveNode(() => renderSidebar());
|
||||
});
|
||||
|
||||
const btnRenumber = createInstantBtn("pi pi-sort-numeric-down", "Renumber All Nodes by Execution Order (Alt+R)", async () => {
|
||||
await renumberNodesTopologically();
|
||||
});
|
||||
|
||||
const divider = document.createElement("div");
|
||||
Object.assign(divider.style, {
|
||||
width: "1px", height: "14px", background: "var(--border-color, #444)",
|
||||
margin: "0 2px", opacity: "0.6"
|
||||
});
|
||||
|
||||
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
|
||||
await clearCancelledOrFailed();
|
||||
renderSidebar();
|
||||
@@ -156,6 +270,9 @@ export function setupSidebarView() {
|
||||
renderSidebar();
|
||||
});
|
||||
|
||||
actionsGroup.appendChild(btnToggleIgnore);
|
||||
actionsGroup.appendChild(btnRenumber);
|
||||
actionsGroup.appendChild(divider);
|
||||
actionsGroup.appendChild(btnClearInterrupted);
|
||||
actionsGroup.appendChild(btnClearAll);
|
||||
standardHeader.appendChild(actionsGroup);
|
||||
@@ -213,7 +330,13 @@ export function setupSidebarView() {
|
||||
|
||||
const cardStack = document.createElement("div");
|
||||
store.ui.cardStack = cardStack;
|
||||
Object.assign(cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" });
|
||||
Object.assign(cardStack.style, {
|
||||
flex: "1",
|
||||
overflowY: "visible",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "28px"
|
||||
});
|
||||
container.appendChild(cardStack);
|
||||
|
||||
scrollToTopBtnEl = document.createElement("button");
|
||||
@@ -233,9 +356,9 @@ export function setupSidebarView() {
|
||||
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";
|
||||
if (cols !== currentColumnCount) {
|
||||
currentColumnCount = cols;
|
||||
renderSidebar();
|
||||
}
|
||||
}).observe(container);
|
||||
|
||||
@@ -389,7 +512,11 @@ export function renderSidebar() {
|
||||
resetDelete();
|
||||
|
||||
if (shouldDeleteFromDisk && img && img.filename) {
|
||||
await deleteFileOnServer(img);
|
||||
const action = await deleteFileOnServer(img);
|
||||
if (action) {
|
||||
const msg = action === "trashed" ? "Moved file to Recycle Bin." : "Permanently deleted file.";
|
||||
showNotification(msg, "success", "Delete File");
|
||||
}
|
||||
}
|
||||
|
||||
const pid = batchInfo.parentPromptId || batchInfo.pid;
|
||||
@@ -504,14 +631,7 @@ export function renderSidebar() {
|
||||
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);
|
||||
}
|
||||
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
|
||||
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) scrollEl.scrollTop = 0;
|
||||
@@ -568,7 +688,8 @@ export function renderSidebar() {
|
||||
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"
|
||||
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px",
|
||||
gridColumn: "1 / -1"
|
||||
});
|
||||
emptyCard.textContent = "No separate intermediate outputs found.";
|
||||
targetElements.push(emptyCard);
|
||||
@@ -662,8 +783,17 @@ export function renderSidebar() {
|
||||
resetDelete();
|
||||
|
||||
if (shouldDeleteFromDisk && out.images) {
|
||||
let count = 0;
|
||||
let lastAct = "trashed";
|
||||
for (const imgItem of out.images) {
|
||||
await deleteFileOnServer(imgItem);
|
||||
const action = await deleteFileOnServer(imgItem);
|
||||
if (action) { count++; lastAct = action; }
|
||||
}
|
||||
if (count > 0) {
|
||||
const msg = lastAct === "trashed"
|
||||
? `Moved ${count} file${count > 1 ? "s" : ""} to Recycle Bin.`
|
||||
: `Permanently deleted ${count} file${count > 1 ? "s" : ""}.`;
|
||||
showNotification(msg, "success", "Delete Files");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,6 +879,7 @@ export function renderSidebar() {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, {
|
||||
pid: st.pid,
|
||||
nodeId: out.nodeId,
|
||||
status: PromptStatus.COMPLETED,
|
||||
images: out.images,
|
||||
workflow: st.workflow,
|
||||
@@ -777,13 +908,10 @@ export function renderSidebar() {
|
||||
});
|
||||
}
|
||||
|
||||
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 (outputs.length === 0) {
|
||||
mountCards(store.ui.cardStack, targetElements, [], currentColumnCount);
|
||||
} else {
|
||||
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
|
||||
}
|
||||
|
||||
updateScrollTopBtnVisibility();
|
||||
@@ -817,7 +945,8 @@ export function renderSidebar() {
|
||||
return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode);
|
||||
};
|
||||
|
||||
const targetElements = [];
|
||||
const bannerElements = [];
|
||||
const cardElements = [];
|
||||
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
@@ -827,8 +956,8 @@ export function renderSidebar() {
|
||||
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"
|
||||
textAlign: "center", fontSize: "12px", fontWeight: "bold",
|
||||
color: "var(--desc-color, #aaa)", display: "flex", flexDirection: "column", gap: "8px"
|
||||
});
|
||||
const textDiv = document.createElement("div");
|
||||
el.appendChild(textDiv);
|
||||
@@ -846,7 +975,7 @@ export function renderSidebar() {
|
||||
cardPool.set("pending-summary-card", pCard);
|
||||
}
|
||||
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
|
||||
targetElements.push(pCard.element);
|
||||
bannerElements.push(pCard.element);
|
||||
} else {
|
||||
let btnCard = cardPool.get("pending-cancel-all-standalone");
|
||||
if (!btnCard) {
|
||||
@@ -854,7 +983,7 @@ export function renderSidebar() {
|
||||
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"
|
||||
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%"
|
||||
});
|
||||
btn.onclick = async () => {
|
||||
await cancelAllPending();
|
||||
@@ -862,23 +991,22 @@ export function renderSidebar() {
|
||||
btnCard = { element: btn };
|
||||
cardPool.set("pending-cancel-all-standalone", btnCard);
|
||||
}
|
||||
targetElements.push(btnCard.element);
|
||||
pendingTasks.forEach(st => targetElements.push(syncTaskCard(st)));
|
||||
bannerElements.push(btnCard.element);
|
||||
pendingTasks.forEach(st => cardElements.push(syncTaskCard(st)));
|
||||
}
|
||||
}
|
||||
|
||||
activeTasks.forEach(st => targetElements.push(syncTaskCard(st)));
|
||||
completedTasks.forEach(st => targetElements.push(syncTaskCard(st)));
|
||||
activeTasks.forEach(st => cardElements.push(syncTaskCard(st)));
|
||||
completedTasks.forEach(st => cardElements.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);
|
||||
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
|
||||
const containerWidth = store.ui.sidebarContainer?.clientWidth || 0;
|
||||
if (containerWidth > 0) {
|
||||
currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
|
||||
}
|
||||
|
||||
mountCards(store.ui.cardStack, bannerElements, cardElements, currentColumnCount);
|
||||
|
||||
if (store.ui.mainQueueScrollTop !== null) {
|
||||
const restorePos = store.ui.mainQueueScrollTop;
|
||||
store.ui.mainQueueScrollTop = null;
|
||||
|
||||
+292
-40
@@ -3,13 +3,110 @@ import { create3DViewer } from "./viewer3d.js";
|
||||
import { createTextReader } from "./text_reader.js";
|
||||
import { stopAllAudioPlayback } from "../ui/audioController.js";
|
||||
import { SidebarOverlay } from "./overlay.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
|
||||
let activeComparisonViewer = null;
|
||||
let isRuntimePreviewActive = false;
|
||||
let onRuntimePreviewStateChange = null;
|
||||
|
||||
export function isRuntimePreviewEnabled() {
|
||||
return isRuntimePreviewActive;
|
||||
}
|
||||
|
||||
export function getActiveComparisonViewer() {
|
||||
return activeComparisonViewer;
|
||||
}
|
||||
|
||||
export function setRuntimePreviewStateListener(fn) {
|
||||
onRuntimePreviewStateChange = fn;
|
||||
}
|
||||
|
||||
export function setRuntimePreviewEnabled(enabled, triggerFocus = true) {
|
||||
isRuntimePreviewActive = !!enabled;
|
||||
if (typeof onRuntimePreviewStateChange === "function") {
|
||||
try { onRuntimePreviewStateChange(isRuntimePreviewActive); } catch (e) {}
|
||||
}
|
||||
|
||||
if (isRuntimePreviewActive && triggerFocus) {
|
||||
focusRuntimePreviewTarget();
|
||||
}
|
||||
}
|
||||
|
||||
function isLatestOrActiveTask(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
const targetPid = String(pid);
|
||||
const activePid = store.ui.currentlyActivePromptId;
|
||||
|
||||
// If a job is actively generating in the background:
|
||||
// ONLY that active job is considered active. Any other card is an older card!
|
||||
if (activePid) {
|
||||
return String(activePid) === targetPid;
|
||||
}
|
||||
|
||||
// If the queue is idle, only the newest prompt in history is the latest
|
||||
const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
if (all.length > 0 && String(all[0].pid) === targetPid) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function focusRuntimePreviewTarget() {
|
||||
try {
|
||||
const activePid = store.ui.currentlyActivePromptId;
|
||||
const activePrompt = activePid ? store.getPrompt(activePid) : null;
|
||||
|
||||
// 1. If a prompt is actively running, focus it (even if it has no image yet)
|
||||
if (activePrompt && activePrompt.status === PromptStatus.ACTIVE) {
|
||||
const previewSrc = activePrompt._previewBlobUrl ||
|
||||
(activePrompt.images && activePrompt.images.length > 0 ? (activePrompt.images[0].url || activePrompt.images[0].filename) : null);
|
||||
showFullscreenPreview([previewSrc || ""], false, activePrompt.pid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Otherwise focus the latest task in history (even if cancelled or text-only)
|
||||
const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
const latest = all[0];
|
||||
|
||||
if (latest) {
|
||||
const firstImg = latest.images && latest.images.length > 0 ? latest.images[0] : null;
|
||||
const randParam = latest.pid ? `&rand=${encodeURIComponent(latest.pid)}` : "";
|
||||
const src = latest._previewBlobUrl ||
|
||||
(firstImg ? (firstImg.url || `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}`) : "");
|
||||
showFullscreenPreview([src || ""], false, latest.pid);
|
||||
} else {
|
||||
// Queue is totally empty
|
||||
showFullscreenPreview([""], false, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Error focusing runtime preview target:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
}
|
||||
|
||||
export function updateActiveComparisonPreview(pid, newSrc) {
|
||||
if (!activeComparisonViewer) return;
|
||||
const targetPidStr = String(pid);
|
||||
|
||||
// If Runtime Preview is OFF: NEVER interrupt the user while inspecting older cards!
|
||||
if (!isRuntimePreviewActive) {
|
||||
if (activeComparisonViewer.targetPid === targetPidStr) {
|
||||
activeComparisonViewer.loadTarget(newSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If Runtime Preview is ON: always track the active generation
|
||||
activeComparisonViewer.targetPid = targetPidStr;
|
||||
activeComparisonViewer.loadTarget(newSrc);
|
||||
}
|
||||
|
||||
const getClientX = (e) => {
|
||||
if (e.touches && e.touches.length > 0) return e.touches[0].clientX;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientX;
|
||||
@@ -27,6 +124,9 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
|
||||
|
||||
const overlay = new SidebarOverlay({
|
||||
onDestroy,
|
||||
onUserClose: () => {
|
||||
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -41,7 +141,8 @@ function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () =>
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "12px",
|
||||
padding: "24px 28px", width: "90%", maxWidth: "520px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.7)", display: "flex",
|
||||
flexDirection: "column", gap: "16px", zIndex: "20"
|
||||
flexDirection: "column", gap: "16px", zIndex: "20",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
|
||||
const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav";
|
||||
@@ -364,6 +465,9 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
|
||||
const overlay = new SidebarOverlay({
|
||||
onDestroy,
|
||||
onUserClose: () => {
|
||||
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === " " && mediaB) {
|
||||
e.preventDefault();
|
||||
@@ -372,12 +476,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Inset 6px left/right so sidebar resize handles stay 100% accessible
|
||||
const scrollContainer = document.createElement("div");
|
||||
Object.assign(scrollContainer.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
position: "absolute", top: "0", left: "6px", right: "6px", width: "auto", height: "100%",
|
||||
display: "flex", overflow: "auto", boxSizing: "border-box",
|
||||
padding: "48px 24px", scrollbarWidth: "thin",
|
||||
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
|
||||
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
overlay.container.appendChild(scrollContainer);
|
||||
|
||||
@@ -404,14 +510,70 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
margin: "auto", flexShrink: "0",
|
||||
cursor: isBaseVideo ? "default" : "zoom-in"
|
||||
cursor: isBaseVideo ? "default" : "zoom-in",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
scrollContainer.appendChild(wrapper);
|
||||
|
||||
// Large 60px Lucide Spinner with relaxed 1.3s rotation
|
||||
const showPlaceholder = () => {
|
||||
mediaA.style.display = "none";
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
|
||||
let placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder");
|
||||
if (!placeholder) {
|
||||
placeholder = document.createElement("div");
|
||||
placeholder.className = "comfy-sidebar-viewer-placeholder";
|
||||
Object.assign(placeholder.style, {
|
||||
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
padding: "36px", borderRadius: "12px", color: "#94a3b8", textAlign: "center",
|
||||
userSelect: "none", pointerEvents: "auto", minWidth: "120px", minHeight: "120px",
|
||||
boxSizing: "border-box", margin: "auto"
|
||||
});
|
||||
wrapper.appendChild(placeholder);
|
||||
}
|
||||
|
||||
const spinnerSvg = `
|
||||
<style>
|
||||
@keyframes comfy-sidebar-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation: comfy-sidebar-spin 1.3s linear infinite; filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.45));">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
placeholder.innerHTML = spinnerSvg;
|
||||
placeholder.style.display = "flex";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.cursor = "default";
|
||||
};
|
||||
|
||||
const hidePlaceholder = () => {
|
||||
const placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder");
|
||||
if (placeholder) placeholder.style.display = "none";
|
||||
mediaA.style.display = "block";
|
||||
};
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
mediaA.onerror = () => {
|
||||
showPlaceholder();
|
||||
};
|
||||
wrapper.appendChild(mediaA);
|
||||
let isZoomed = false;
|
||||
|
||||
if (!baseSrc) {
|
||||
showPlaceholder();
|
||||
}
|
||||
|
||||
const slider = document.createElement("div");
|
||||
Object.assign(slider.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "50%",
|
||||
@@ -451,16 +613,32 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
if (!mediaA) return;
|
||||
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
|
||||
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
|
||||
if (!wA || !hA) return;
|
||||
|
||||
// Calculate available space inside the preview area
|
||||
const availW = Math.max(100, (scrollContainer.clientWidth || window.innerWidth) - 48);
|
||||
const availH = Math.max(100, (scrollContainer.clientHeight || window.innerHeight) - 96);
|
||||
const aspectA = wA / hA;
|
||||
|
||||
// Scale to fit available space while maintaining aspect ratio (fills screen even for small previews)
|
||||
let fitW = availW;
|
||||
let fitH = fitW / aspectA;
|
||||
if (fitH > availH) {
|
||||
fitH = availH;
|
||||
fitW = fitH * aspectA;
|
||||
}
|
||||
fitW = Math.round(fitW);
|
||||
fitH = Math.round(fitH);
|
||||
|
||||
if (!mediaB) {
|
||||
slider.style.display = "none";
|
||||
wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in");
|
||||
if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto";
|
||||
if (isZoomed && wA && hA) {
|
||||
if (isZoomed) {
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = `${wA}px`;
|
||||
wrapper.style.height = `${hA}px`;
|
||||
wrapper.style.cursor = "zoom-out";
|
||||
mediaA.style.maxWidth = "none";
|
||||
mediaA.style.maxHeight = "none";
|
||||
mediaA.style.width = "100%";
|
||||
@@ -469,21 +647,20 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
} else {
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
|
||||
mediaA.style.maxWidth = "calc(100vw - 120px)";
|
||||
mediaA.style.maxHeight = "calc(100vh - 120px)";
|
||||
mediaA.style.width = "auto";
|
||||
mediaA.style.height = "auto";
|
||||
mediaA.style.objectFit = "contain";
|
||||
wrapper.style.width = `${fitW}px`;
|
||||
wrapper.style.height = `${fitH}px`;
|
||||
mediaA.style.maxWidth = "none";
|
||||
mediaA.style.maxHeight = "none";
|
||||
mediaA.style.width = "100%";
|
||||
mediaA.style.height = "100%";
|
||||
mediaA.style.objectFit = "fill";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wB = mediaB.naturalWidth || mediaB.videoWidth || 0;
|
||||
const hB = mediaB.naturalHeight || mediaB.videoHeight || 0;
|
||||
if (!wA || !hA || !wB || !hB) return;
|
||||
if (!wB || !hB) return;
|
||||
|
||||
const arA = wA / hA;
|
||||
const arB = wB / hB;
|
||||
@@ -508,21 +685,31 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
}
|
||||
|
||||
slider.style.display = "block";
|
||||
wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in");
|
||||
if (mediaA) mediaA.style.pointerEvents = "none";
|
||||
if (mediaB) mediaB.style.pointerEvents = "none";
|
||||
infoText.style.color = "#aaa";
|
||||
infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card to compare | Esc to close.";
|
||||
infoText.textContent = "Click image to zoom | Drag image to compare slider | Esc to close.";
|
||||
updateSliderPosition(50);
|
||||
|
||||
const maxW = Math.max(wA, wB);
|
||||
const maxH = Math.max(hA, hB);
|
||||
const aspectComp = maxW / maxH;
|
||||
|
||||
let fitWComp = availW;
|
||||
let fitHComp = fitWComp / aspectComp;
|
||||
if (fitHComp > availH) {
|
||||
fitHComp = availH;
|
||||
fitWComp = fitHComp * aspectComp;
|
||||
}
|
||||
fitWComp = Math.round(fitWComp);
|
||||
fitHComp = Math.round(fitHComp);
|
||||
|
||||
if (isZoomed) {
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = `${maxW}px`;
|
||||
wrapper.style.height = `${maxH}px`;
|
||||
wrapper.style.cursor = "zoom-out";
|
||||
|
||||
[mediaA, mediaB].forEach(el => {
|
||||
if (el) {
|
||||
@@ -536,24 +723,42 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
} else {
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.cursor = "zoom-in";
|
||||
wrapper.style.width = `${fitWComp}px`;
|
||||
wrapper.style.height = `${fitHComp}px`;
|
||||
|
||||
[mediaA, mediaB].forEach(el => {
|
||||
if (el) {
|
||||
el.style.maxWidth = "calc(100vw - 120px)";
|
||||
el.style.maxHeight = "calc(100vh - 120px)";
|
||||
el.style.width = "auto";
|
||||
el.style.height = "auto";
|
||||
el.style.objectFit = "contain";
|
||||
el.style.maxWidth = "none";
|
||||
el.style.maxHeight = "none";
|
||||
el.style.width = "100%";
|
||||
el.style.height = "100%";
|
||||
el.style.objectFit = "fill";
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
mediaA.onload = syncImageScales;
|
||||
if (mediaA.complete) syncImageScales();
|
||||
const onMediaReady = () => {
|
||||
hidePlaceholder();
|
||||
syncImageScales();
|
||||
};
|
||||
|
||||
if (mediaA.tagName === "VIDEO") {
|
||||
mediaA.addEventListener("loadedmetadata", onMediaReady);
|
||||
if (mediaA.readyState >= 1) onMediaReady();
|
||||
} else {
|
||||
mediaA.onload = onMediaReady;
|
||||
if (mediaA.complete && mediaA.naturalWidth) onMediaReady();
|
||||
}
|
||||
|
||||
// Dynamically adjust preview scale if sidebars are resized
|
||||
if (window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!isZoomed) syncImageScales();
|
||||
});
|
||||
ro.observe(scrollContainer);
|
||||
overlay.addCleanup(() => ro.disconnect());
|
||||
}
|
||||
|
||||
const toggleZoom = () => {
|
||||
if (isBaseVideo) return;
|
||||
@@ -579,20 +784,29 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
if (!isDraggingSlider) return;
|
||||
const clientX = getClientX(e);
|
||||
const clientY = getClientY(e);
|
||||
if (!dragMoved && (Math.abs(clientX - dragStartX) > 4 || Math.abs(clientY - dragStartY) > 4)) {
|
||||
|
||||
// Distinguish click from drag: only snap when mouse moves more than 3px
|
||||
if (!dragMoved && (Math.abs(clientX - dragStartX) > 3 || Math.abs(clientY - dragStartY) > 3)) {
|
||||
dragMoved = true;
|
||||
}
|
||||
|
||||
// Click + Drag: snap slider to mouse pointer and drag smoothly
|
||||
if (dragMoved && mediaB) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
if (rect.width > 0) {
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!isDraggingSlider) return;
|
||||
isDraggingSlider = false;
|
||||
if (dragMoved) setTimeout(() => { dragMoved = false; }, 50);
|
||||
if (dragMoved) {
|
||||
// Keep dragMoved true briefly so the upcoming click event doesn't trigger toggleZoom
|
||||
setTimeout(() => { dragMoved = false; }, 60);
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener("mousedown", startDrag);
|
||||
@@ -609,14 +823,17 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
window.removeEventListener("touchend", endDrag);
|
||||
});
|
||||
|
||||
wrapper.addEventListener("click", () => {
|
||||
wrapper.addEventListener("click", (e) => {
|
||||
if (dragMoved || isBaseVideo) return;
|
||||
toggleZoom();
|
||||
});
|
||||
|
||||
scrollContainer.addEventListener("click", (e) => {
|
||||
if (dragMoved) return;
|
||||
if (e.target === scrollContainer || e.target === overlay.container) overlay.destroy();
|
||||
if (e.target === scrollContainer || e.target === overlay.container) {
|
||||
overlay.onUserClose?.();
|
||||
overlay.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
if (isBaseVideo) {
|
||||
@@ -640,8 +857,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
const isTargetText = typeof targetSrc === "object" && targetSrc.text;
|
||||
|
||||
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
|
||||
const currentPid = activeComparisonViewer?.targetPid;
|
||||
overlay.destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
showFullscreenPreview([targetSrc], isShiftClick, currentPid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetSrc) {
|
||||
showPlaceholder();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -663,8 +886,10 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
hidePlaceholder();
|
||||
mediaA.src = targetSrc;
|
||||
mediaA.onload = () => {
|
||||
hidePlaceholder();
|
||||
infoText.style.color = "#aaa";
|
||||
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
|
||||
syncImageScales();
|
||||
@@ -675,15 +900,31 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
const item = imgSrcs[0];
|
||||
const targetPid = pid || (typeof item === "object" && item ? item.pid : null);
|
||||
const pidStr = targetPid ? String(targetPid) : null;
|
||||
|
||||
// Automatically manage Runtime Preview mode based on the card clicked
|
||||
if (pidStr) {
|
||||
if (isLatestOrActiveTask(pidStr)) {
|
||||
if (!isRuntimePreviewActive) setRuntimePreviewEnabled(true, false);
|
||||
} else {
|
||||
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserClose = () => {
|
||||
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
|
||||
};
|
||||
|
||||
// Handle Text Output
|
||||
if (typeof item === "object" && item.text) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isText) {
|
||||
activeComparisonViewer.targetPid = pidStr;
|
||||
activeComparisonViewer.loadTarget(item);
|
||||
return;
|
||||
}
|
||||
@@ -692,9 +933,11 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
}
|
||||
activeComparisonViewer = createTextReader(
|
||||
item,
|
||||
(target) => showFullscreenPreview([target]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
(target) => showFullscreenPreview([target], false, pidStr),
|
||||
() => { activeComparisonViewer = null; },
|
||||
handleUserClose
|
||||
);
|
||||
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -704,6 +947,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (is3DFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.is3D) {
|
||||
activeComparisonViewer.targetPid = pidStr;
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
@@ -712,9 +956,11 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
}
|
||||
activeComparisonViewer = create3DViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
|
||||
() => { activeComparisonViewer = null; },
|
||||
handleUserClose
|
||||
);
|
||||
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -722,6 +968,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (isAudioFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isAudio) {
|
||||
activeComparisonViewer.targetPid = pidStr;
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
@@ -730,9 +977,10 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
}
|
||||
activeComparisonViewer = createAudioViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -742,6 +990,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
} else {
|
||||
activeComparisonViewer.targetPid = pidStr;
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
}
|
||||
@@ -751,4 +1000,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
src,
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.targetPid = pidStr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { syncAllNodeBadges } from "../comfy/nodes.js";
|
||||
|
||||
export function showNotification(message, type = "success", title = null) {
|
||||
const summary = title || (type === "error" ? "Error" : type === "warn" ? "Warning" : "Comfy Sidebar");
|
||||
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: type === "error" ? "error" : type === "warn" ? "warn" : "success",
|
||||
summary,
|
||||
detail: message,
|
||||
life: 4000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Elegant non-blocking fallback if native toast is unavailable
|
||||
const id = "comfy-sidebar-toast";
|
||||
let toast = document.getElementById(id);
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = id;
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid ${type === "error" ? "#ef4444" : type === "warn" ? "#f59e0b" : "#10b981"};
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 99999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
max-width: 380px;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
`;
|
||||
toast.innerHTML = `<strong style="color:${type === "error" ? "#f87171" : type === "warn" ? "#fbbf24" : "#34d399"}">${summary}</strong><div style="margin-top:2px;">${message}</div>`;
|
||||
requestAnimationFrame(() => {
|
||||
toast.style.opacity = "1";
|
||||
toast.style.transform = "translateY(0)";
|
||||
});
|
||||
clearTimeout(toast._timeout);
|
||||
toast._timeout = setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateY(10px)";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
export async function renumberNodesTopologically() {
|
||||
if (!app.graph || !app.graph._nodes || app.graph._nodes.length === 0) {
|
||||
showNotification("No nodes found on canvas to renumber.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
"Renumbering will rewrite all node IDs on the canvas in topological execution order.\n\n" +
|
||||
"This will fix intermediate node outputs showing as the final result, but may break external scripts that rely on specific node IDs\n\n" +
|
||||
"Do you want to proceed?"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// Compute LiteGraph's topological execution order
|
||||
if (typeof app.graph.computeExecutionOrder === "function") {
|
||||
app.graph.computeExecutionOrder(false);
|
||||
}
|
||||
|
||||
// Sort nodes by order (lowest executes first; highest executes last)
|
||||
const sortedNodes = [...app.graph._nodes].sort((a, b) => {
|
||||
const orderA = typeof a.order === "number" ? a.order : Infinity;
|
||||
const orderB = typeof b.order === "number" ? b.order : Infinity;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
|
||||
const posXA = Array.isArray(a.pos) ? a.pos[0] : 0;
|
||||
const posXB = Array.isArray(b.pos) ? b.pos[0] : 0;
|
||||
if (posXA !== posXB) return posXA - posXB;
|
||||
|
||||
const posYA = Array.isArray(a.pos) ? a.pos[1] : 0;
|
||||
const posYB = Array.isArray(b.pos) ? b.pos[1] : 0;
|
||||
return posYA - posYB;
|
||||
});
|
||||
|
||||
const oldToNew = new Map();
|
||||
sortedNodes.forEach((node, index) => {
|
||||
const newId = index + 1;
|
||||
oldToNew.set(node.id, newId);
|
||||
oldToNew.set(String(node.id), newId);
|
||||
});
|
||||
|
||||
// 1. Remap node IDs in-place on app.graph
|
||||
app.graph._nodes_by_id = {};
|
||||
sortedNodes.forEach(node => {
|
||||
const oldId = node.id;
|
||||
const newId = oldToNew.get(oldId);
|
||||
if (newId != null) {
|
||||
// Update internal widget IDs like "$2034-0" (PreviewBridge)
|
||||
if (Array.isArray(node.widgets)) {
|
||||
for (const w of node.widgets) {
|
||||
if (typeof w.value === "string" && w.value.startsWith(`$${oldId}-`)) {
|
||||
w.value = w.value.replace(`$${oldId}-`, `$${newId}-`);
|
||||
}
|
||||
}
|
||||
}
|
||||
node.id = newId;
|
||||
app.graph._nodes_by_id[newId] = node;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Remap links
|
||||
if (app.graph.links) {
|
||||
const links = app.graph.links instanceof Map
|
||||
? Array.from(app.graph.links.values())
|
||||
: Object.values(app.graph.links);
|
||||
|
||||
links.forEach(link => {
|
||||
if (!link) return;
|
||||
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
|
||||
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
|
||||
});
|
||||
}
|
||||
|
||||
app.graph.last_node_id = sortedNodes.length;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas?.draw) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
syncAllNodeBadges();
|
||||
|
||||
const lastNode = sortedNodes[sortedNodes.length - 1];
|
||||
const lastNodeName = lastNode ? (lastNode.title || lastNode.type) : "SaveImage";
|
||||
showNotification(`Renumbered ${sortedNodes.length} nodes by execution order. Latest node: #${lastNode.id} (${lastNodeName}).`, "success");
|
||||
} catch (err) {
|
||||
console.error("ComfySidebar: Failed to renumber nodes", err);
|
||||
showNotification("Failed to renumber nodes: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setManualNodeId(targetNode = null) {
|
||||
if (!app.graph) return;
|
||||
const rawSelected = app.canvas?.selected_nodes;
|
||||
const selectedList = rawSelected instanceof Map || rawSelected instanceof Set
|
||||
? Array.from(rawSelected.values())
|
||||
: (Array.isArray(rawSelected) ? rawSelected : (rawSelected && typeof rawSelected === "object" ? Object.values(rawSelected) : []));
|
||||
const node = targetNode || selectedList[0] || app.canvas?.current_active_node || null;
|
||||
if (!node) {
|
||||
showNotification("Please select a node first to change its ID.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentId = node.id;
|
||||
const input = prompt(`Enter new ID for node #${currentId} (${node.title || node.type}):`, String(currentId));
|
||||
if (!input) return;
|
||||
|
||||
const newId = parseInt(input.trim(), 10);
|
||||
if (isNaN(newId) || newId <= 0) {
|
||||
showNotification("Please enter a valid positive number.", "error");
|
||||
return;
|
||||
}
|
||||
if (newId === currentId) return;
|
||||
|
||||
try {
|
||||
const existingNode = app.graph.getNodeById ? app.graph.getNodeById(newId) : null;
|
||||
if (existingNode && existingNode !== node) {
|
||||
const confirmSwap = confirm(`Node ID #${newId} is already used by "${existingNode.title || existingNode.type}". Swap their IDs?`);
|
||||
if (!confirmSwap) return;
|
||||
}
|
||||
|
||||
const oldToNew = new Map();
|
||||
if (existingNode && existingNode !== node) {
|
||||
oldToNew.set(currentId, newId);
|
||||
oldToNew.set(newId, currentId);
|
||||
existingNode.id = currentId;
|
||||
} else {
|
||||
oldToNew.set(currentId, newId);
|
||||
}
|
||||
node.id = newId;
|
||||
|
||||
// Rebuild lookup map
|
||||
app.graph._nodes_by_id = {};
|
||||
for (const n of app.graph._nodes) {
|
||||
app.graph._nodes_by_id[n.id] = n;
|
||||
}
|
||||
|
||||
// Remap links
|
||||
if (app.graph.links) {
|
||||
const links = app.graph.links instanceof Map
|
||||
? Array.from(app.graph.links.values())
|
||||
: Object.values(app.graph.links);
|
||||
|
||||
links.forEach(link => {
|
||||
if (!link) return;
|
||||
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
|
||||
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
|
||||
});
|
||||
}
|
||||
|
||||
app.graph.last_node_id = Math.max(app.graph.last_node_id || 0, newId, app.graph._nodes.length);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas?.draw) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
syncAllNodeBadges();
|
||||
showNotification(`Assigned node ID #${newId} to "${node.title || node.type}".`, "success");
|
||||
} catch (err) {
|
||||
console.error("ComfySidebar: Failed to set node ID", err);
|
||||
showNotification("Failed to set node ID: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
+104
-33
@@ -1,11 +1,17 @@
|
||||
import { store } from "../core/store.js";
|
||||
import { findActiveSidebars, findPropertiesPanel } from "../comfy/adapter.js";
|
||||
|
||||
export class SidebarOverlay {
|
||||
constructor(options = {}) {
|
||||
this.onDestroy = options.onDestroy || (() => {});
|
||||
this.onUserClose = options.onUserClose || (() => {});
|
||||
this.cleanupFns = [];
|
||||
this.observedElements = new Set();
|
||||
|
||||
// Root container
|
||||
// Elevate sidebars above overlay
|
||||
document.body.classList.add("comfy-sidebar-overlay-active");
|
||||
|
||||
// Root container: visual backdrop (covers 100% flush from sidebar to sidebar, zero gap)
|
||||
this.container = document.createElement("div");
|
||||
this.container.className = `comfy-sidebar-comparison-overlay ${options.className || ""}`;
|
||||
Object.assign(this.container.style, {
|
||||
@@ -15,9 +21,23 @@ export class SidebarOverlay {
|
||||
color: "var(--fg-color, #eee)",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
justifyContent: "center", zIndex: "1000", boxSizing: "border-box",
|
||||
overflow: "hidden", pointerEvents: "auto", userSelect: "none"
|
||||
overflow: "hidden", pointerEvents: "none", userSelect: "none"
|
||||
});
|
||||
|
||||
// Interactive backdrop: insets 6px from each sidebar so the border zones pass clicks to the resizers
|
||||
this.backdrop = document.createElement("div");
|
||||
Object.assign(this.backdrop.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "6px", right: "6px",
|
||||
pointerEvents: "auto", zIndex: "1"
|
||||
});
|
||||
this.backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === this.backdrop) {
|
||||
this.onUserClose();
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
this.container.appendChild(this.backdrop);
|
||||
|
||||
// Close button
|
||||
this.closeBtn = document.createElement("span");
|
||||
this.closeBtn.className = "pi pi-times";
|
||||
@@ -29,7 +49,7 @@ export class SidebarOverlay {
|
||||
transition: "all 0.15s ease",
|
||||
background: "var(--comfy-input-bg, rgba(20, 20, 20, 0.6))",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.1))",
|
||||
borderRadius: "50%", padding: "6px"
|
||||
borderRadius: "50%", padding: "6px", pointerEvents: "auto"
|
||||
});
|
||||
this.closeBtn.onmouseenter = () => {
|
||||
this.closeBtn.style.color = "var(--fg-color, #fff)";
|
||||
@@ -40,13 +60,19 @@ export class SidebarOverlay {
|
||||
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
|
||||
};
|
||||
|
||||
this.closeBtn.onclick = () => this.destroy();
|
||||
this.closeBtn.onclick = () => {
|
||||
this.onUserClose();
|
||||
this.destroy();
|
||||
};
|
||||
this.container.appendChild(this.closeBtn);
|
||||
|
||||
// Backdrop click to close
|
||||
this.container.addEventListener("click", (e) => {
|
||||
if (e.target === this.container) this.destroy();
|
||||
});
|
||||
// ResizeObserver to track live drag-resizing on all active sidebars
|
||||
if (window.ResizeObserver) {
|
||||
this.ro = new ResizeObserver(() => {
|
||||
this.updateOverlayBounds();
|
||||
});
|
||||
this.cleanupFns.push(() => this.ro.disconnect());
|
||||
}
|
||||
|
||||
// Sidebar boundary alignment
|
||||
this.updateOverlayBounds = this.updateOverlayBounds.bind(this);
|
||||
@@ -54,17 +80,27 @@ export class SidebarOverlay {
|
||||
window.addEventListener("resize", this.updateOverlayBounds);
|
||||
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
|
||||
|
||||
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
|
||||
if (sidebarEl && window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(this.updateOverlayBounds);
|
||||
ro.observe(sidebarEl);
|
||||
this.cleanupFns.push(() => ro.disconnect());
|
||||
// Live observer for opening/closing panels or layout changes
|
||||
if (window.MutationObserver) {
|
||||
let updateScheduled = false;
|
||||
const mo = new MutationObserver(() => {
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.updateOverlayBounds();
|
||||
updateScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "class"] });
|
||||
this.cleanupFns.push(() => mo.disconnect());
|
||||
}
|
||||
|
||||
// Global Key Handler (Esc to close)
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
this.onUserClose();
|
||||
this.destroy();
|
||||
}
|
||||
if (options.onKeyDown) options.onKeyDown(e);
|
||||
@@ -75,29 +111,62 @@ export class SidebarOverlay {
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
observeElement(el) {
|
||||
if (!el || !this.ro || this.observedElements.has(el)) return;
|
||||
this.observedElements.add(el);
|
||||
this.ro.observe(el);
|
||||
}
|
||||
|
||||
updateOverlayBounds() {
|
||||
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
|
||||
if (sidebarEl && sidebarEl.offsetWidth > 0 && sidebarEl.isConnected) {
|
||||
const rect = sidebarEl.getBoundingClientRect();
|
||||
if (rect.left < window.innerWidth / 2) {
|
||||
const leftOffset = Math.max(0, rect.right);
|
||||
this.container.style.left = `${leftOffset}px`;
|
||||
this.container.style.width = `calc(100vw - ${leftOffset}px)`;
|
||||
this.container.style.right = "0px";
|
||||
} else {
|
||||
const rightOffset = Math.max(0, window.innerWidth - rect.left);
|
||||
this.container.style.left = "0px";
|
||||
this.container.style.width = `calc(100vw - ${rightOffset}px)`;
|
||||
this.container.style.right = `${rightOffset}px`;
|
||||
let leftOffset = 0;
|
||||
let rightOffset = 0;
|
||||
let topOffset = 0;
|
||||
|
||||
const winW = window.innerWidth;
|
||||
const midX = winW / 2;
|
||||
|
||||
try {
|
||||
const { leftPanels, rightPanels } = findActiveSidebars();
|
||||
|
||||
[...leftPanels, ...rightPanels].forEach(p => this.observeElement(p));
|
||||
|
||||
for (const el of leftPanels) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.right > 0 && rect.right < midX) {
|
||||
leftOffset = Math.max(leftOffset, Math.round(rect.right));
|
||||
}
|
||||
if (rect.top > 0 && rect.top < 120) {
|
||||
topOffset = Math.max(topOffset, Math.round(rect.top));
|
||||
}
|
||||
}
|
||||
this.container.style.top = `${Math.max(0, rect.top)}px`;
|
||||
this.container.style.height = `calc(100vh - ${Math.max(0, rect.top)}px)`;
|
||||
} else {
|
||||
this.container.style.left = "0px";
|
||||
this.container.style.width = "100vw";
|
||||
this.container.style.top = "0px";
|
||||
this.container.style.height = "100vh";
|
||||
|
||||
for (const el of rightPanels) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.left > midX && rect.left < winW) {
|
||||
rightOffset = Math.max(rightOffset, Math.round(winW - rect.left));
|
||||
}
|
||||
if (rect.top > 0 && rect.top < 120) {
|
||||
topOffset = Math.max(topOffset, Math.round(rect.top));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Error finding active sidebars:", e);
|
||||
}
|
||||
|
||||
// Safety clamp: offsets can never exceed half screen width
|
||||
leftOffset = Math.min(midX - 50, Math.max(0, leftOffset));
|
||||
rightOffset = Math.min(midX - 50, Math.max(0, rightOffset));
|
||||
topOffset = Math.max(0, topOffset);
|
||||
|
||||
this.container.style.left = `${leftOffset}px`;
|
||||
this.container.style.right = `${rightOffset}px`;
|
||||
this.container.style.width = `calc(100vw - ${leftOffset + rightOffset}px)`;
|
||||
this.container.style.top = `${topOffset}px`;
|
||||
this.container.style.height = `calc(100vh - ${topOffset}px)`;
|
||||
|
||||
// Crisp 1px visible separator lines along active sidebar borders
|
||||
this.container.style.borderLeft = leftOffset > 0 ? "1px solid var(--border-color, rgba(255, 255, 255, 0.22))" : "none";
|
||||
this.container.style.borderRight = rightOffset > 0 ? "1px solid var(--border-color, rgba(255, 255, 255, 0.22))" : "none";
|
||||
}
|
||||
|
||||
addCleanup(fn) {
|
||||
@@ -105,6 +174,8 @@ export class SidebarOverlay {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.body.classList.remove("comfy-sidebar-overlay-active");
|
||||
|
||||
for (const fn of this.cleanupFns.splice(0)) {
|
||||
try { fn(); } catch (e) {}
|
||||
}
|
||||
|
||||
+81
-4
@@ -6,7 +6,7 @@ export function injectStyles() {
|
||||
.comfy-sidebar-card {
|
||||
background: var(--comfy-input-bg, #181818);
|
||||
border-radius: 4px; padding: 8px; position: relative;
|
||||
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
|
||||
min-height: 80px; margin-bottom: 0px; break-inside: avoid;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
border: 2px solid var(--border-color, #333);
|
||||
color: var(--comfy-input-color, var(--fg-color, #eee));
|
||||
@@ -30,7 +30,7 @@ export function injectStyles() {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Prevent action hover buttons (Delete/JSON/Node) from showing on pending & running cards */
|
||||
/* Prevent action hover buttons from showing on pending & running cards */
|
||||
.comfy-sidebar-card.pending .comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-card.active .comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-card.pending .comfy-sidebar-left-hover-panel,
|
||||
@@ -41,7 +41,7 @@ export function injectStyles() {
|
||||
.comfy-sidebar-card-timer {
|
||||
position: absolute; top: 6px; left: 8px; font-size: 10px;
|
||||
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
|
||||
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
|
||||
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 10; color: #fff;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
@@ -57,6 +57,7 @@ export function injectStyles() {
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
z-index: 25 !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn:hover {
|
||||
@@ -338,6 +339,26 @@ export function injectStyles() {
|
||||
}
|
||||
|
||||
/* Audio Preview Cards */
|
||||
/* Generic Background Thumbnail for Non-Image Cards (Audio, 3D, File) */
|
||||
.comfy-sidebar-card-bg-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.35);
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper:hover .comfy-sidebar-card-bg-img,
|
||||
.comfy-sidebar-file-wrapper:hover .comfy-sidebar-card-bg-img {
|
||||
filter: brightness(0.5);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
@@ -412,7 +433,7 @@ export function injectStyles() {
|
||||
100% { transform: scaleY(1.3); }
|
||||
}
|
||||
|
||||
/* Generic / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
|
||||
/* Generic File Preview Cards */
|
||||
.comfy-sidebar-file-wrapper {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
@@ -424,6 +445,7 @@ export function injectStyles() {
|
||||
padding: 8px 10px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
@@ -512,6 +534,61 @@ export function injectStyles() {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
/* Elevate outer sidebar dock containers above the preview overlay */
|
||||
body.comfy-sidebar-overlay-active aside,
|
||||
body.comfy-sidebar-overlay-active nav,
|
||||
body.comfy-sidebar-overlay-active .comfyui-sidebar,
|
||||
body.comfy-sidebar-overlay-active .side-tool-bar-container,
|
||||
body.comfy-sidebar-overlay-active .sidebar-content-container,
|
||||
body.comfy-sidebar-overlay-active .comfyui-sidebar-content,
|
||||
body.comfy-sidebar-overlay-active .p-sidebar,
|
||||
body.comfy-sidebar-overlay-active .p-sidebar-right,
|
||||
body.comfy-sidebar-overlay-active [data-testid="properties-panel"],
|
||||
body.comfy-sidebar-overlay-active [data-testid="workflow-overview-panel"],
|
||||
body.comfy-sidebar-overlay-active [class*="properties-panel"],
|
||||
body.comfy-sidebar-overlay-active [class*="resize-handle"],
|
||||
body.comfy-sidebar-overlay-active [class*="resizer"],
|
||||
body.comfy-sidebar-overlay-active [class*="splitter"],
|
||||
body.comfy-sidebar-overlay-active [class*="gutter"],
|
||||
body.comfy-sidebar-overlay-active [role="separator"] {
|
||||
z-index: 1005 !important;
|
||||
}
|
||||
|
||||
/* Explicit card element layering so badges and [X] are ALWAYS on top of images */
|
||||
.comfy-sidebar-card .comfy-sidebar-media-wrapper {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.comfy-sidebar-card-timer {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
z-index: 25 !important;
|
||||
}
|
||||
.comfy-sidebar-status-badge {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
|
||||
/* Slim down the Queue sidebar scrollbar so it stops colliding with the separator */
|
||||
.comfyui-sidebar ::-webkit-scrollbar,
|
||||
.comfy-sidebar ::-webkit-scrollbar,
|
||||
[class*="sidebar-content"] ::-webkit-scrollbar,
|
||||
.sidebar-content-container ::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
.comfyui-sidebar ::-webkit-scrollbar-thumb,
|
||||
.comfy-sidebar ::-webkit-scrollbar-thumb,
|
||||
[class*="sidebar-content"] ::-webkit-scrollbar-thumb,
|
||||
.sidebar-content-container ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
/* Smooth animation for the circular loader */
|
||||
@keyframes comfy-sidebar-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
+35
-11
@@ -39,7 +39,7 @@ function parseInlineMarkdown(html) {
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<span style="text-decoration:line-through;color:#888;">$1</span>');
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#61afef;text-decoration:none;border-bottom:1px dashed #61afef;cursor:pointer;">$1</a>');
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#61afef;text-decoration:none;border-bottom:1px dashed #61afef;cursor:pointer;">$1</a>');
|
||||
html = html.replace(/\$(?!\s)([^\$]+?)(?<!\s)\$/g, '<span style="font-family:\'Times New Roman\',Times,serif;font-size:14px;color:#e5c07b;font-style:italic;padding:0 2px;">$1</span>');
|
||||
return html;
|
||||
}
|
||||
@@ -192,12 +192,12 @@ function parseMarkdown(text) {
|
||||
return htmlLines.join("\n");
|
||||
}
|
||||
|
||||
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}, onUserClose = () => {}) {
|
||||
let rawText = typeof textData === "object" ? (textData.text || "") : String(textData);
|
||||
let pid = typeof textData === "object" ? textData.pid : "";
|
||||
let isMarkdownMode = true;
|
||||
|
||||
const overlay = new SidebarOverlay({ onDestroy });
|
||||
const overlay = new SidebarOverlay({ onDestroy, onUserClose });
|
||||
|
||||
const readerBox = document.createElement("div");
|
||||
Object.assign(readerBox.style, {
|
||||
@@ -208,25 +208,48 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
|
||||
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex",
|
||||
flexDirection: "column", gap: "14px", zIndex: "20",
|
||||
color: "var(--fg-color, #eee)"
|
||||
color: "var(--fg-color, #eee)",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
|
||||
const formatTitle = (id) => {
|
||||
if (!id) return "Text Output";
|
||||
const shortId = id.length > 10 ? `${id.slice(0, 8)}…` : id;
|
||||
return `Text Output #${shortId}`;
|
||||
};
|
||||
|
||||
const headerRow = document.createElement("div");
|
||||
Object.assign(headerRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px" });
|
||||
Object.assign(headerRow.style, {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px",
|
||||
flexWrap: "wrap", gap: "10px"
|
||||
});
|
||||
|
||||
const titleInfo = document.createElement("div");
|
||||
Object.assign(titleInfo.style, { display: "flex", alignItems: "center", gap: "10px" });
|
||||
Object.assign(titleInfo.style, {
|
||||
display: "flex", alignItems: "center", gap: "10px",
|
||||
minWidth: "0", flex: "1 1 auto", overflow: "hidden"
|
||||
});
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
|
||||
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f8fafc", whiteSpace: "nowrap" });
|
||||
title.textContent = formatTitle(pid);
|
||||
title.title = pid ? `Text Output #${pid}` : "Text Output";
|
||||
Object.assign(title.style, {
|
||||
fontSize: "14px", fontWeight: "bold", color: "#f8fafc",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexShrink: "1"
|
||||
});
|
||||
|
||||
const statsBadge = document.createElement("span");
|
||||
Object.assign(statsBadge.style, { fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)", padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace", whiteSpace: "nowrap" });
|
||||
Object.assign(statsBadge.style, {
|
||||
fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)",
|
||||
padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace",
|
||||
whiteSpace: "nowrap", flexShrink: "0"
|
||||
});
|
||||
|
||||
titleInfo.append(title, statsBadge);
|
||||
|
||||
const actionsRow = document.createElement("div");
|
||||
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center" });
|
||||
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" });
|
||||
|
||||
const modeBtn = document.createElement("button");
|
||||
Object.assign(modeBtn.style, {
|
||||
@@ -328,7 +351,8 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
|
||||
}
|
||||
rawText = targetData.text || "";
|
||||
pid = targetData.pid || "";
|
||||
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
|
||||
title.textContent = formatTitle(pid);
|
||||
title.title = pid ? `Text Output #${pid}` : "Text Output";
|
||||
updateView();
|
||||
},
|
||||
destroy: () => overlay.destroy()
|
||||
|
||||
+33
-7
@@ -292,18 +292,18 @@ export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
const val = outputs[nodeId][key];
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach(item => {
|
||||
if (typeof item === 'string' && !FILE_EXT_REGEX.test(item.trim())) {
|
||||
if (typeof item === 'string' && item.trim().length > 0 && !FILE_EXT_REGEX.test(item.trim())) {
|
||||
list.push(item);
|
||||
} else if (item && typeof item === 'object' && item.text) {
|
||||
if (Array.isArray(item.text)) list.push(...item.text);
|
||||
else if (typeof item.text === 'string') list.push(item.text);
|
||||
if (Array.isArray(item.text)) list.push(...item.text.filter(t => typeof t === 'string' && t.trim().length > 0));
|
||||
else if (typeof item.text === 'string' && item.text.trim().length > 0) list.push(item.text);
|
||||
}
|
||||
});
|
||||
} else if (typeof val === 'string' && !FILE_EXT_REGEX.test(val.trim())) {
|
||||
} else if (typeof val === 'string' && val.trim().length > 0 && !FILE_EXT_REGEX.test(val.trim())) {
|
||||
list.push(val);
|
||||
} else if (val && typeof val === 'object' && val.text) {
|
||||
if (Array.isArray(val.text)) list.push(...val.text);
|
||||
else if (typeof val.text === 'string') list.push(val.text);
|
||||
if (Array.isArray(val.text)) list.push(...val.text.filter(t => typeof t === 'string' && t.trim().length > 0));
|
||||
else if (typeof val.text === 'string' && val.text.trim().length > 0) list.push(val.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,26 @@ export function getRunOutputs(nodeOutputs, rawWorkflow, promptGraph = null) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
|
||||
// Track node execution order so the final executed node is always sorted last
|
||||
const nodeOrderMap = new Map();
|
||||
if (workflow && Array.isArray(workflow.nodes)) {
|
||||
workflow.nodes.forEach((n, idx) => {
|
||||
const order = typeof n.order === "number" ? n.order : idx;
|
||||
nodeOrderMap.set(String(n.id), order);
|
||||
});
|
||||
}
|
||||
|
||||
const nodeIds = Object.keys(nodeOutputs);
|
||||
// Sort node IDs by real execution order (lowest executes first; highest executes last)
|
||||
nodeIds.sort((a, b) => {
|
||||
const orderA = nodeOrderMap.has(String(a)) ? nodeOrderMap.get(String(a)) : Infinity;
|
||||
const orderB = nodeOrderMap.has(String(b)) ? nodeOrderMap.get(String(b)) : Infinity;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
const nodeOut = nodeOutputs[nodeId];
|
||||
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
|
||||
@@ -339,6 +358,13 @@ export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
|
||||
// Find the latest executed node that actually produced real output images
|
||||
for (let i = runOutputs.length - 1; i >= 0; i--) {
|
||||
const imgs = runOutputs[i].images || [];
|
||||
if (imgs.some(img => !img.isFallback)) {
|
||||
return imgs;
|
||||
}
|
||||
}
|
||||
return runOutputs[runOutputs.length - 1].images || [];
|
||||
}
|
||||
|
||||
|
||||
+12
-19
@@ -36,19 +36,9 @@ async function getThreeLibs() {
|
||||
};
|
||||
|
||||
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;
|
||||
const base = "https://esm.sh/";
|
||||
window.__COMFY_SIDEBAR_THREE_BASE = base;
|
||||
const { three, controls, gltf, obj, stl, ply } = await loadFromBase(base);
|
||||
cachedThreeLibs = {
|
||||
...three,
|
||||
OrbitControls: controls.OrbitControls || controls.default || controls,
|
||||
@@ -64,10 +54,11 @@ async function getThreeLibs() {
|
||||
}
|
||||
}
|
||||
|
||||
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}, onUserClose = () => {}) {
|
||||
const overlay = new SidebarOverlay({
|
||||
className: "comfy-sidebar-3d-overlay-root",
|
||||
onDestroy
|
||||
onDestroy,
|
||||
onUserClose
|
||||
});
|
||||
|
||||
const header = document.createElement("div");
|
||||
@@ -232,7 +223,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
|
||||
|
||||
const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/";
|
||||
const base = window.__COMFY_SIDEBAR_THREE_BASE || "https://esm.sh/";
|
||||
if (format === "OBJ") {
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
|
||||
const OBJExporter = mod.OBJExporter || mod.default || mod;
|
||||
@@ -283,9 +274,11 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
THREE = await getThreeLibs();
|
||||
if (!THREE) {
|
||||
loadingSpinner.innerHTML = `
|
||||
<div style="text-align: center; max-width: 320px; line-height: 1.4;">
|
||||
<span style="color: #f87171; font-weight: bold;">Failed to load 3D engine.</span><br>
|
||||
<span style="font-size: 11px; color: #94a3b8;">Blocked by ComfyUI Content Security Policy (CSP). Check if <code>--disable-api-nodes</code> is enabled in your startup arguments, or vendor Three.js locally.</span>
|
||||
<div style="text-align: center; max-width: 340px; line-height: 1.4; padding: 6px;">
|
||||
<span style="color: #f87171; font-weight: bold; font-size: 13px;">Failed to load 3D engine.</span><br>
|
||||
<span style="font-size: 11px; color: #cbd5e1; display: inline-block; margin-top: 4px;">
|
||||
Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
[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.5.0"
|
||||
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
|
||||
version = "1.7.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"send2trash>=1.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "soundslikethunder"
|
||||
DisplayName = "Comfy Sidebar"
|
||||
DisplayName = "Comfy Sidebar"
|
||||
Icon = "https://raw.githubusercontent.com/m0rtus59/ComfyUI-ComfySidebar/main/assets/icon.png"
|
||||
@@ -0,0 +1 @@
|
||||
send2trash>=1.8.0
|
||||
Reference in New Issue
Block a user