Compare commits

...
49 Commits
Author SHA1 Message Date
m0rtus59 25520c8612 Update readme. Fix compare slider. Version bump 2026-09-26 20:19:06 +05:00
m0rtus59 949220a260 New 'runtime preview' mode 2026-09-26 00:09:42 +05:00
m0rtus59 e3d943a353 More fullscreen preview improvements 2026-09-25 22:09:07 +05:00
m0rtus59 929928aa97 Fullscreen preview improvements 2026-09-25 21:52:16 +05:00
m0rtus59 dc868f298f Cache integrity check fix 2026-09-25 21:13:14 +05:00
m0rtus59 cdd4d399c8 Add comfy registry icon 2026-09-18 21:41:12 +05:00
m0rtus59 70c8e64c4e More hardening. File deletion logic improvements 2026-09-18 21:04:55 +05:00
m0rtus59 1f3c3cb598 hardening 2026-09-17 22:11:32 +05:00
m0rtus59 0238cf109d New 'reset node IDs' function to ensure the final output node is always prioritized as the primary preview 2026-09-17 02:04:43 +05:00
m0rtus59 869d3b9a28 fixes 2026-09-13 19:37:54 +05:00
m0rtus59 f09e2c7921 Improve card stacking for multiple column setup 2026-09-13 11:37:31 +05:00
m0rtus59 9adfb2a53f Improve 'view intermediate' button logic 2026-09-12 22:04:51 +05:00
m0rtus59 a17314673e Job preview fixes and improvements 2026-09-11 00:59:32 +05:00
m0rtus59 8343f216fc Refactor. Improve light theme compatibility. Add offline 3D viewer & CSP support. Improve file deletion logic 2026-09-10 21:57:14 +05:00
m0rtus59 3e7448dd80 fix: workflow persistence on restart, card copy button, and submenu stability 2026-09-06 20:57:34 +05:00
m0rtus59 b93413a729 fixes and improvements 2026-08-28 23:02:52 +05:00
m0rtus59 782d30b490 add ctrl+click for alt actions on cards 2026-08-28 22:02:42 +05:00
m0rtus59 0a2db6b85f add support for misc files 2026-08-28 21:32:46 +05:00
m0rtus59 5a4c9f99a5 improve preview, drag-drop, and queue handling 2026-08-28 20:38:46 +05:00
m0rtus59 141a0872d5 minor fixes 2026-08-22 18:12:03 +05:00
m0rtus59 108ac61bec bump 2026-08-22 17:50:24 +05:00
m0rtus59 b31cfb5f8a remove duplicate tip 2026-08-22 15:48:22 +05:00
M0rtus59 fe154b7787 Refactor overlay system, debounce state saving, and fix pending card controls
- Unified Overlay System: Reusable SidebarOverlay for all viewers.

- Performance: Debounced localStorage persistence to prevent UI stutters.

- Memory Cleanup: Proper disposal of global document/API listeners.

- UI Fix: Pending/active cards only show cancel button on hover.
2026-08-14 23:40:07 +05:00
M0rtus59 da5756d61c Add support for 3D, Audio-only, and text-only outputs 2026-08-14 22:40:00 +05:00
M0rtus59 370f4fc58c Preview and compare menu improvements 2026-08-14 20:13:20 +05:00
M0rtus59 73a9092001 Updated for frontend version 1.48.7+. Add 3D files output support (TBD) 2026-08-13 23:20:56 +05:00
m0rtus59 9797d1ac7a refactor: align layout, settings, and node hooks with native ComfyUI frontend API 2026-08-10 22:00:17 +05:00
m0rtus59 d0424d0d05 update README 2026-08-09 10:12:50 +05:00
m0rtus59 c72407690c version bump 2026-08-09 09:56:10 +05:00
m0rtus59 31c994a1b6 Missing preview cards cleanup. Hover highlight glitch fix on action buttons 2026-08-09 09:49:55 +05:00
m0rtus59 9219f2d042 perf(sidebar): optimize storage footprint 2026-08-08 22:02:10 +05:00
m0rtus59 b3e6b1bd62 version bump 2026-08-08 08:08:32 +05:00
m0rtus59 2dc92d3e6f perf(queue): optimize rendering overhead and fix preview image flicker 2026-08-08 07:58:45 +05:00
m0rtus59 e83c999da7 bug fixes and improvements 2026-07-28 22:38:52 +05:00
m0rtus59 f61d6c8902 Bump version number 2026-07-27 17:56:12 +05:00
m0rtus59 689e020230 Optimize card action button styles 2026-07-26 23:34:19 +05:00
m0rtus59 ad4e138eac Fixed 'toggle properties panel' button in Comfy Layout with left-sided sidebar 2026-07-26 23:13:06 +05:00
m0rtus59 8487150e6f bump version number 2026-07-25 00:44:42 +05:00
m0rtus59 0e120f1af1 bug fixes and optimizations 2026-07-25 00:42:01 +05:00
m0rtus59 b6bdaefacc 'Replace stock job history bar' toggle now overrides the floating job history bar. Tidy up legacy code 2026-07-24 23:27:39 +05:00
m0rtus59 5adf5ea513 Add Comfy Registry integration and setup automatic publisher 2026-07-20 20:53:09 +05:00
m0rtus59 f6e76b1789 Fixed layout for user avatar and new extension manager 2026-07-20 20:20:34 +05:00
m0rtus59 0e648a9fa9 intermediate step cards display dimensions 2026-07-19 16:36:04 +05:00
M0rtus59 684726789f Show intermediate results on cancelled runs if any. Improved drag&drop logic 2026-07-19 14:22:59 +05:00
M0rtus59 c2c82065a7 Drag&drop for unfinished tasks hooks up the workflow JSON. Updated readme 2026-07-19 12:26:17 +05:00
M0rtus59 5fde92b51a Added 'Ignore in queue' toggle 2026-07-19 11:52:29 +05:00
m0rtus59 c43863a219 Modify preview compare slider instructions in README
Updated the instructions for using the preview compare slider to include the shift+click action.
2026-07-19 09:56:48 +05:00
M0rtus59 111de1a8c2 Improved comparison 2026-07-19 09:55:12 +05:00
M0rtus59 92653bfa75 minor fixes 2026-07-19 00:56:46 +05:00
42 changed files with 8663 additions and 2547 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Publish to Comfy registry & GitHub Release
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
permissions:
contents: write # Gives GitHub permission to create tags & releases
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
# 1. Read version string from pyproject.toml
- name: Extract Version
id: get_version
run: |
VERSION=$(grep -m 1 'version' pyproject.toml | cut -d '"' -f 2)
echo "VERSION=$VERSION" >> $GITHUB_ENV
# 2. Automatically create Git Tag & GitHub Release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.VERSION }}
name: v${{ env.VERSION }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+61 -19
View File
@@ -1,39 +1,81 @@
# ComfyUI-ComfySidebar
A lightweight, high-performance ComfyUI extension that replaces the cluttered stock Job History/Queue interface with a compact drag&drop queue panel, inspired by the pre-vue ComfyUI frontend design.
> ⚠️ **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
* **Advanced Drag & Drop Support**: Designed specifically for precise mouse pointer controls. Easily drag previous generations back to the empty canvas to restore workflows, or drop images directly into `LoadImage` and input nodes to hot-swap them.
* **High-Visibility Preview Cards**: Keep your past runs instantly recognizable. Supports aspect-ratio locked thumbnails for images and auto-playing muted loops for video formats, as well as clean rendering for multi-line text outputs.
* **Quick Save Buttons**: Dedicated interactive shortcuts appear on hover, allowing you to instantly download generation outputs or export exact workflow JSON files with a single click.
* **Compact Adaptive Design**: A clean, smart layout that automatically balances single or multi-column grids based on your sidebar width. It maximizes viewable content and respects your active screen area without wasting precious space.
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
* A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
* Toggles to hide buttons you don't need from the side panel.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then click another to load it alongside the first one for pixel by pixel comparison.
* **Compact, information-dense layout** with visual previews that let you see what is happening at a glance. Optional UI elements and sidebar tabs can be hidden when you don't need them.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
<img src="assets/layout.png" alt="ComfySidebar layout" width="80%">
* **Left or right side:** Choose which side the sidebar appears on in ComfyUI settings.
* **Simplified mouse-friendly workflow** with drag-and-drop support and convenient actions for moving results back into the workflow, opening files, and managing outputs. Hold `Ctrl` for extra actions such as opening an image's location or deleting it directly from your device.
<img src="assets/controls.png" alt="ComfySidebar controls overview" width="80%">
* **Built-in comparison tool** for quickly comparing two generated results side by side.
<img src="assets/compare.png" alt="ComfySidebar compare" width="80%">
* **Runtime preview mode** combined with workflow overview makes ComfyUI practical for running and monitoring workflows in a more streamlined, queue-oriented way.
<img src="assets/runtime.png" alt="ComfySidebar runtime mode" width="80%">
## 🛠️ Installation
Clone the repository directly into your ComfyUI custom nodes directory:
### ComfyUI Registry
Install directly from the ComfyUI Manager or run:
```bash
comfy node install comfy-sidebar
```
ComfySidebar is available on the [ComfyUI Registry](https://registry.comfy.org/publishers/soundslikethunder/nodes/comfy-sidebar).
### Manual installation
Clone the repository into your ComfyUI custom nodes directory:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/m0rtus59/ComfyUI-ComfySidebar.git
```
Restart your ComfyUI server and refresh the browser tab.
Restart ComfyUI and refresh the browser.
## ⚙️ Configuration
## ⚙️ Settings
Additional tweaks are seamlessly integrated into the standard ComfyUI Settings panel. From there, you can adjust thresholds or fully replace the stock V2 panel by enabling options like **"Override Stock Job History Tab"**.
Open **Settings → Comfy Sidebar** to configure:
## ⌨️ Shortcuts
* Queue layout and column width
* Queue display mode
* Working node display
* 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
* **`Q`**: Instantly toggles the sidebar panel open or closed. Safely ignores inputs if you are currently typing inside a text field, input area, or combo box.
## ⌨️ Controls
| 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 depends on parts of its UI structure. Frontend changes in ComfyUI may require updates to this extension.
## License
See [LICENSE](LICENSE).
+194 -11
View File
@@ -1,20 +1,203 @@
# Standard ComfyUI extension loader
from server import PromptServer
import os
import platform
import subprocess
from aiohttp import web
import uuid
from server import PromptServer
import folder_paths
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Tells ComfyUI to serve and load the contents of the js/ folder
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Create a unique session ID for this backend boot to track server restarts
server_session_id = str(uuid.uuid4())
# Register a custom route to check for backend restarts
@PromptServer.instance.routes.get("/classic-sidebar/session")
async def get_session_id(request):
return web.json_response({"session_id": server_session_id})
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)
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"):
"""Gathers all potential ComfyUI base directories (output, model folders, extra paths)."""
roots = []
try:
roots.append(folder_paths.get_output_directory())
except Exception:
pass
# Include specific model categories (loras, checkpoints, etc.)
categories = ["loras", "checkpoints", "vae", "unet", "clip", "embeddings", "upscale_models"]
if subfolder and subfolder in folder_paths.folder_names_and_paths:
try:
roots.extend(folder_paths.get_folder_paths(subfolder))
except Exception:
pass
for cat in categories:
try:
roots.extend(folder_paths.get_folder_paths(cat))
except Exception:
pass
try:
roots.append(folder_paths.get_input_directory())
roots.append(folder_paths.get_temp_directory())
except Exception:
pass
# Deduplicate while preserving existence
unique = []
for r in roots:
if r and r not in unique and os.path.exists(r):
unique.append(r)
return unique
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 = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
if filename:
for root in candidates:
if subfolder:
p1 = os.path.join(root, subfolder, filename)
if os.path.isfile(p1) and is_path_safe(root, p1):
return p1, root
p2 = os.path.join(root, filename)
if os.path.isfile(p2) and is_path_safe(root, p2):
return p2, root
for root in candidates:
if subfolder:
d1 = os.path.join(root, subfolder)
if os.path.isdir(d1) and is_path_safe(root, d1):
return d1, root
if os.path.isdir(root):
return root, root
return None, None
def open_file_in_os(target_path):
"""Fast, native cross-platform explorer launch."""
system = platform.system()
if system == "Windows":
norm_path = os.path.normpath(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}"])
else:
subprocess.Popen(["explorer", norm_path])
elif system == "Darwin": # macOS
if not os.path.exists(target_path):
raise FileNotFoundError(f"Path not found: {target_path}")
if os.path.isfile(target_path):
subprocess.Popen(["open", "-R", target_path])
else:
subprocess.Popen(["open", target_path])
else: # Linux / Unix
folder = os.path.dirname(target_path) if os.path.isfile(target_path) else target_path
if not os.path.exists(folder):
raise FileNotFoundError(f"Path not found: {folder}")
if "DISPLAY" in os.environ or "WAYLAND_DISPLAY" in os.environ:
subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
async def open_folder_handler(request):
try:
data = await request.json()
filename = (data.get("filename") or "").strip()
subfolder = (data.get("subfolder") or "").strip()
folder_type = (data.get("type") or "output").strip()
target_path, _ = resolve_existing_path(filename, subfolder, folder_type)
if not target_path:
target_path = folder_paths.get_output_directory()
open_file_in_os(target_path)
return web.json_response({"success": True, "path": target_path})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@PromptServer.instance.routes.post("/comfy-sidebar/delete-file")
async def delete_file_handler(request):
try:
data = await request.json()
filename = (data.get("filename") or "").strip()
subfolder = (data.get("subfolder") or "").strip()
folder_type = (data.get("type") or "output").strip()
if not filename:
return web.json_response({"error": "No filename provided"}, status=400)
# 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 outside output directory"}, status=404)
# Try moving to Recycle Bin / Trash first, fallback to os.remove
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, "action": action})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/comfy-sidebar/promote-output")
async def promote_output_handler(request):
try:
import shutil
data = await request.json()
raw_filename = (data.get("filename") or "").strip()
filename = os.path.basename(raw_filename)
subfolder = (data.get("subfolder") or "").strip()
folder_type = (data.get("type") or "output").strip()
target_file, root = resolve_existing_path(raw_filename, subfolder, folder_type)
if not target_file or not os.path.isfile(target_file):
return web.json_response({"error": "File not found"}, status=404)
input_dir = folder_paths.get_input_directory()
dest_path = os.path.join(input_dir, filename)
if not is_path_safe(input_dir, dest_path):
return web.json_response({"error": "Unsafe destination path"}, status=403)
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+279
View File
@@ -0,0 +1,279 @@
import { app } from "/scripts/app.js";
export function findOriginalPropertiesButton() {
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return byTestId;
}
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
for (const icon of icons) {
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return btn;
}
}
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
return btn;
}
}
return null;
}
export function findTopbarContainer() {
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalPropertiesButton();
if (orig?.parentNode) return orig.parentNode;
const buttons = document.querySelectorAll("button, .p-button");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("run") || title.includes("manager")) {
if (btn.parentNode) return btn.parentNode;
}
}
return null;
}
export function 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") ||
origBtn.classList.contains("active") ||
origBtn.getAttribute("aria-expanded") === "true" ||
origBtn.getAttribute("aria-pressed") === "true") {
return true;
}
}
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;
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const text = (btn.textContent || "").toLowerCase();
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
return btn;
}
}
return null;
}
export function findNativeExtensionsPanel() {
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
}
export function findGraphButton() {
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
for (const el of elements) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
return el.closest('.group, .p-1') || el;
}
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
for (const el of fallbacks) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
}
}
return null;
}
export function findOurSidebarButton() {
const 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() {
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') || '';
if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) &&
!btn.querySelector('.pi-images') &&
!btn.id?.includes('classic-comfy-sidebar')) {
return btn;
}
}
return null;
}
export function updateSidebarBadge(count) {
const 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() {
const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
if (stdBtn) {
if (!stdBtn._originalDisplay) {
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
}
if (overrideStock) {
stdBtn.style.setProperty("display", "none", "important");
if (!stdBtn._overrideClickListener) {
stdBtn._overrideClickListener = (e) => {
e.preventDefault();
e.stopPropagation();
const b = findOurSidebarButton();
if (b) b.click();
};
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
}
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) {
stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
}
} else {
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
if (stdBtn._overrideClickListener) {
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
stdBtn._overrideClickListener = null;
}
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
stdBtn.parentNode.appendChild(ourBtn);
}
}
}
}
export function centerAndSelectCanvasNode(nodeId) {
if (!app.graph || !app.canvas) return false;
const id = typeof nodeId === "string" ? Number(nodeId) : nodeId;
const node = app.graph.getNodeById ? (app.graph.getNodeById(id) || app.graph.getNodeById(String(nodeId))) : null;
if (node) {
if (app.canvas.centerOnNode) app.canvas.centerOnNode(node);
if (app.canvas.selectNode) app.canvas.selectNode(node);
return true;
}
return false;
}
+552
View File
@@ -0,0 +1,552 @@
import { app } from "/scripts/app.js";
import {
findOriginalPropertiesButton,
findTopbarContainer,
isPropertiesPanelOpen,
findActiveQueueIndicator,
findNativeExtensionsPanel,
findGraphButton,
findPropertiesPanel
} from "./adapter.js";
import { isRuntimePreviewEnabled, setRuntimePreviewEnabled, setRuntimePreviewStateListener } from "../utils/comparison.js";
// Listen for runtime preview state changes to keep button UI in sync
setRuntimePreviewStateListener(() => {
syncRuntimePreviewButton();
});
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
.actionbar-container,
.shadow-interface.rounded-lg.bg-comfy-menu-bg,
.shadow-interface.rounded-lg.border-interface-stroke,
.shadow-interface.border.rounded-lg:has([class*="actionbar-buttons"]),
.shadow-interface.border.rounded-lg:has(.actionbar-buttons),
div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
position: fixed !important;
top: 3px !important;
right: 4px !important;
left: auto !important;
transform: none !important;
z-index: 1010 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
}
.comfy-sidebar-extensions-override {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 8px 0 0 !important;
height: auto !important;
position: static !important;
order: -10 !important;
display: inline-flex !important;
align-items: center !important;
}
#crysmonitor-monitors-root,
#crystools-monitors-root,
.crysmonitor-monitors-container {
zoom: 0.8 !important;
display: inline-flex !important;
align-items: center !important;
}
img[alt*="User Avatar"],
img[alt*="user avatar"],
button:has(img[alt*="User Avatar"]),
[role="button"]:has(img[alt*="User Avatar"]),
button:has(img[alt*="user avatar"]),
[role="button"]:has(img[alt*="user avatar"]),
.p-avatar,
[data-pc-name="avatar"],
button:has(.p-avatar),
[role="button"]:has(.p-avatar),
button:has([data-pc-name="avatar"]),
[role="button"]:has([data-pc-name="avatar"]) {
display: none !important;
}
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 150px !important;
padding-right: 580px !important;
}
@media (max-width: 1599px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 100px !important;
padding-right: 420px !important;
}
}
@media (max-width: 1199px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 40px !important;
padding-right: 280px !important;
}
}
@media (max-width: 899px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 10px !important;
padding-right: 180px !important;
}
}
`;
export function syncStockHistoryAndProgressSettings(enable) {
const dockedVal = !!enable;
const progressVal = !enable;
const QPOV2_ID = "Comfy.Queue.QPOV2";
const progressKey = "Comfy.Queue.ShowRunProgressBar";
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
}
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
}
export function applyClassicLayout(enable, updateSetting = false) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
}
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
} else {
if (styleEl) styleEl.remove();
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
}
let savedButtonData = null;
let domObserver = null;
let syncScheduled = false;
function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
const tabSelectors = {
"Assets": '[data-testid="assets-tab-button"], [class*="comfy--image-ai-edit"]',
"Nodes": '[data-testid="node-library-tab-button"], [class*="comfy--node"]',
"Models": '[data-testid="model-library-tab-button"], [class*="comfy--ai-model"]',
"Workflows": '[data-testid="workflows-tab-button"], [class*="comfy--workflow"], [class*="workflow"]',
"Apps": '[data-testid="apps-tab-button"], [class*="lucide--panels-top-left"]',
"Templates": '[data-testid="templates-tab-button"], [class*="comfy--template"]'
};
Object.entries(tabSelectors).forEach(([tab, selector]) => {
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
const target = sidebar.querySelector(selector);
if (target) {
const tabBtn = target.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || target;
if (shouldHide) {
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
} else {
if (tabBtn.style.display === "none") tabBtn.style.removeProperty("display");
}
}
});
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const historyTarget = sidebar.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"], [class*="lucide--history"]');
if (historyTarget) {
const historyBtn = historyTarget.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyTarget;
if (hideStockHistory) {
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
} else {
if (historyBtn.style.display === "none") historyBtn.style.removeProperty("display");
}
}
}
function syncErrorBadge(originalBtn, customBtn) {
if (!customBtn) return;
const stockDot = document.querySelector('.comfy-sidebar-hide-original-properties-btn ~ span, .comfy-sidebar-hide-original-properties-btn + span');
let customDot = customBtn.querySelector('.comfy-sidebar-error-dot');
if (stockDot) {
if (!customDot) {
customDot = document.createElement("span");
customDot.className = "comfy-sidebar-error-dot inline-flex items-center justify-center rounded-full bg-destructive-background text-white size-2 absolute -top-1 -right-1";
customBtn.appendChild(customDot);
}
} else if (customDot) {
customDot.remove();
}
}
function cleanHTML(html) {
if (!html) return "";
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
const tempDot = tempDiv.querySelector('[class*="destructive"]');
if (tempDot) tempDot.remove();
return tempDiv.innerHTML;
}
function syncGraphButton() {
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
} else {
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
}
}
}
function syncExtensionsPanel(isClassicLayoutEnabled) {
const extensionsPanel = findNativeExtensionsPanel();
if (!extensionsPanel) return;
if (isClassicLayoutEnabled) {
const container = findTopbarContainer();
if (container && extensionsPanel.parentNode !== container) {
if (!extensionsPanel._originalParent) {
extensionsPanel._originalParent = extensionsPanel.parentNode;
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
}
container.appendChild(extensionsPanel);
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
}
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
}
}
function syncPropertiesButton(isClassicLayoutEnabled) {
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalPropertiesButton();
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) customBtn.remove();
return;
}
const originalBtn = findOriginalPropertiesButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
tagName: originalBtn.tagName,
attributes: Array.from(originalBtn.attributes).map(attr => ({
name: attr.name,
value: attr.value
}))
};
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalPropertiesButton();
if (nativeBtn) {
nativeBtn.click();
requestAnimationFrame(() => syncClassicLayout());
}
});
container.appendChild(customBtn);
}
if (customBtn) {
if (originalBtn) {
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
customBtn.innerHTML = cleanedHTML;
if (existingDot) customBtn.appendChild(existingDot);
}
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
customBtn.setAttribute("title", titleVal);
customBtn.setAttribute("aria-label", titleVal);
for (const cls of originalBtn.classList) {
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
customBtn.classList.add(cls);
}
}
syncErrorBadge(originalBtn, customBtn);
} else if (!customBtn.getAttribute("title")) {
customBtn.setAttribute("title", "Toggle properties panel");
customBtn.setAttribute("aria-label", "Toggle properties panel");
}
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
}
function syncActiveQueueIndicator(isClassicLayoutEnabled) {
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const indicator = findActiveQueueIndicator();
if (indicator) {
if (isClassicLayoutEnabled && hideQueueIndicator) {
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
} else if (indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
}
}
export function syncRuntimePreviewButton() {
const panel = findPropertiesPanel();
if (!panel) return;
let dock = panel.querySelector(".comfy-sidebar-runtime-preview-dock");
if (!dock) {
dock = document.createElement("div");
dock.className = "comfy-sidebar-runtime-preview-dock";
Object.assign(dock.style, {
position: "sticky",
bottom: "0px",
left: "0px",
width: "100%",
padding: "8px 12px",
boxSizing: "border-box",
background: "var(--comfy-menu-bg, #181818)",
borderTop: "1px solid var(--border-color, rgba(255, 255, 255, 0.12))",
zIndex: "20",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: "auto"
});
const btn = document.createElement("button");
btn.className = "comfy-sidebar-runtime-preview-btn";
Object.assign(btn.style, {
width: "100%",
height: "34px",
borderRadius: "6px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
fontSize: "12px",
fontWeight: "600",
fontFamily: "sans-serif",
transition: "all 0.15s ease",
outline: "none"
});
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const nextState = !isRuntimePreviewEnabled();
setRuntimePreviewEnabled(nextState, true);
};
dock.appendChild(btn);
panel.appendChild(dock);
}
const btn = dock.querySelector(".comfy-sidebar-runtime-preview-btn");
if (btn) {
const active = isRuntimePreviewEnabled();
const lucidePlay = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><polygon points="6 3 20 12 6 21 6 3"/></svg>`;
const lucideEye = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;flex-shrink:0;"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
const lucideSquareCheck = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/></svg>`;
const lucideSquare = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5;flex-shrink:0;"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>`;
if (active) {
btn.style.background = "rgba(59, 130, 246, 0.18)";
btn.style.border = "1px solid #3b82f6";
btn.style.color = "#93c5fd";
btn.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.35)";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucidePlay}<span>Runtime Preview</span></span>${lucideSquareCheck}`;
btn.title = "Runtime Preview is ON (auto-tracking live generation). Click to turn OFF.";
} else {
btn.style.background = "rgba(255, 255, 255, 0.05)";
btn.style.border = "1px solid var(--border-color, rgba(255, 255, 255, 0.15))";
btn.style.color = "var(--desc-color, #aaa)";
btn.style.boxShadow = "none";
btn.innerHTML = `<span style="display:flex;align-items:center;gap:7px;">${lucideEye}<span>Runtime Preview</span></span>${lucideSquare}`;
btn.title = "Click to turn ON Runtime Preview (auto-focus live generation).";
}
}
}
export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
try {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
syncGraphButton();
syncExtensionsPanel(isClassicLayoutEnabled);
syncPropertiesButton(isClassicLayoutEnabled);
syncActiveQueueIndicator(isClassicLayoutEnabled);
updateSidebarTabsVisibility();
syncRuntimePreviewButton();
} catch (err) {
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
} finally {
if (domObserver) {
domObserver.observe(document.body, {
childList: true,
subtree: true
});
}
}
}
export function setupPropertiesPanelToggleFix() {
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
const style = document.createElement("style");
style.id = "comfy-sidebar-layout-fix-styles";
style.textContent = `
.comfy-sidebar-hide-original-properties-btn {
display: none !important;
}
.comfy-sidebar-hide-original-properties-btn ~ span,
.comfy-sidebar-hide-original-properties-btn + span {
display: none !important;
}
.comfy-sidebar-custom-properties-toggle {
order: 99999 !important;
position: relative !important;
overflow: visible !important;
margin-left: -8px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
margin-left: 0px !important;
}
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
display: inline-flex !important;
}
`;
document.head.appendChild(style);
}
if (domObserver) domObserver.disconnect();
domObserver = new MutationObserver((mutations) => {
let shouldSync = false;
const isInternalSidebarMutation = (node) => {
if (!node || node.nodeType !== 1) return false;
return node.closest?.('.comfyui-sidebar, .comfy-sidebar, .comfy-sidebar-comparison-overlay') ||
node.classList?.contains('comfy-sidebar-card');
};
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
const hasExternalElementMutation =
Array.from(mutation.addedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node)) ||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node));
if (hasExternalElementMutation) {
shouldSync = true;
break;
}
}
}
if (shouldSync && !syncScheduled) {
syncScheduled = true;
requestAnimationFrame(() => {
syncClassicLayout();
syncScheduled = false;
});
}
});
domObserver.observe(document.body, {
childList: true,
subtree: true
});
syncClassicLayout();
}
export function destroyLayoutFix() {
if (domObserver) {
domObserver.disconnect();
domObserver = null;
}
const styleEl = document.getElementById(STYLE_ID);
if (styleEl) styleEl.remove();
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
if (fixStyles) fixStyles.remove();
}
+177
View File
@@ -0,0 +1,177 @@
import { app } from "/scripts/app.js";
import { applySidebarOverride } from "./adapter.js";
let nodeDOMObserver = null;
function findHeaderByText(parent, text) {
if (!text) return null;
const cleanText = text.trim();
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
for (const el of elements) {
if (el.textContent.trim() === cleanText) {
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
return el;
}
}
}
return null;
}
export function syncNodeVueBadge(node, isIgnored) {
const nodeEl = document.querySelector(
`.comfy-node[data-node-id="${node.id}"], ` +
`[data-node-id="${node.id}"], ` +
`[data-id="${node.id}"]`
);
if (!nodeEl) return;
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
if (!titleTextNode) {
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
if (badge && !isIgnored) badge.remove();
return;
}
const headerEl = titleTextNode.parentNode;
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
if (isIgnored) {
if (!badge) {
badge = document.createElement("div");
badge.className = "comfy-sidebar-ignore-badge";
badge.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
<line x1="2" y1="2" x2="22" y2="22"/>
</svg>
`;
Object.assign(badge.style, {
marginLeft: "auto",
marginRight: "6px",
float: "right",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "inherit",
pointerEvents: "none"
});
headerEl.appendChild(badge);
}
} else {
if (badge) badge.remove();
}
}
export function syncAllNodeBadges() {
applySidebarOverride();
if (!app.graph || !app.graph._nodes) return;
app.graph._nodes.forEach(node => {
if (!node.properties) node.properties = {};
const isIgnored = !!node.properties.ignoreInQueue;
if (isIgnored) {
if (node.boxcolor !== "#ff3333") {
node._oldBoxcolor = node.boxcolor || "";
node.boxcolor = "#ff3333";
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
} else {
if (node.boxcolor === "#ff3333") {
node.boxcolor = node._oldBoxcolor || "";
delete node._oldBoxcolor;
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
}
syncNodeVueBadge(node, isIgnored);
});
}
export function setupVueNodeObserver() {
if (nodeDOMObserver) nodeDOMObserver.disconnect();
let timeoutId = null;
nodeDOMObserver = new MutationObserver((mutations) => {
let hasNodeMutation = false;
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) {
if (node.hasAttribute?.("data-node-id") ||
node.classList?.contains("comfy-node") ||
node.querySelector?.('[data-node-id], .comfy-node')) {
hasNodeMutation = true;
break;
}
}
}
if (hasNodeMutation) break;
}
if (hasNodeMutation) {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
syncAllNodeBadges();
}, 50);
}
});
nodeDOMObserver.observe(document.body, {
childList: true,
subtree: true
});
return () => {
if (nodeDOMObserver) {
nodeDOMObserver.disconnect();
nodeDOMObserver = null;
}
};
}
export function toggleIgnoreActiveNode(onModified) {
const canvas = app.canvas;
if (!canvas) return;
let nodes = [];
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
nodes = Object.values(canvas.selected_nodes);
} else if (canvas.current_active_node) {
nodes = [canvas.current_active_node];
}
if (nodes.length === 0) return;
nodes.forEach(node => {
if (!node.properties) node.properties = {};
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
const isIgnored = !!node.properties.ignoreInQueue;
if (isIgnored) {
if (node.boxcolor !== "#ff3333") {
node._oldBoxcolor = node.boxcolor || "";
node.boxcolor = "#ff3333";
}
} else {
if (node.boxcolor === "#ff3333") {
node.boxcolor = node._oldBoxcolor || "";
delete node._oldBoxcolor;
}
}
syncNodeVueBadge(node, isIgnored);
});
if (app.graph) {
if (app.graph.change) app.graph.change();
app.graph.setDirtyCanvas(true, true);
}
if (app.canvas) app.canvas.setDirty(true, true);
if (typeof onModified === "function") {
onModified();
}
}
-341
View File
@@ -1,341 +0,0 @@
import { State } from "./state.js";
import { isVideoFormat } from "./utils.js";
let activeComparisonViewer = null;
let videoSyncActive = false;
let syncAnimationFrameId = null;
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
if (isVideo) {
el.muted = muted;
el.playsInline = true;
el.autoplay = true;
el.loop = true;
el.controls = false; // Disable native controls to prevent layout shifts
}
Object.assign(el.style, {
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
objectFit: "contain", pointerEvents: "none"
});
el.src = isVideo ? src + "#t=0.001" : src;
return el;
};
// Video Playback Manager: Binds timeline progress bar, timeline scrubbing, and play/pause triggers
const setupVideoPlayback = (vid, container) => {
if (!vid) return;
const onMetaLoaded = () => {
vid.play().catch(()=>{});
};
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
// Synced Scrubber Progress Bar Overlay
const controlBar = document.createElement("div");
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px",
boxShadow: "0 2px 8px rgba(0,0,0,0.5)", transform: "translate3d(-50%, 0, 0)",
pointerEvents: "auto"
});
const playBtn = document.createElement("span");
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
playBtn.style.cursor = "pointer";
playBtn.style.fontSize = "14px";
playBtn.onclick = (e) => {
e.stopPropagation();
if (vid.paused) {
vid.play().catch(()=>{});
playBtn.className = "pi pi-pause";
} else {
vid.pause();
playBtn.className = "pi pi-play";
}
};
const scrubberContainer = document.createElement("div");
Object.assign(scrubberContainer.style, {
flex: "1", height: "4px", background: "#444", borderRadius: "2px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, {
width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px"
});
scrubberContainer.appendChild(scrubberFill);
const timeLabel = document.createElement("span");
timeLabel.textContent = "0:00 / 0:00";
const scrub = (e) => {
const rect = scrubberContainer.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
const duration = vid.duration || 0;
if (duration > 0) {
vid.currentTime = (percent / 100) * duration;
scrubberFill.style.width = `${percent}%`;
}
};
let isScrubbing = false;
scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
const handleWindowMove = (e) => { if (isScrubbing) scrub(e); };
const handleWindowUp = () => { isScrubbing = false; };
window.addEventListener("mousemove", handleWindowMove);
window.addEventListener("touchmove", handleWindowMove);
window.addEventListener("mouseup", handleWindowUp);
window.addEventListener("touchend", handleWindowUp);
controlBar.append(playBtn, scrubberContainer, timeLabel);
container.appendChild(controlBar);
videoSyncActive = true;
const syncLoop = () => {
if (!videoSyncActive) return;
// Update progress & labels
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
scrubberFill.style.width = `${(cur / dur) * 100}%`;
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
// Dynamically match play button in case video loops
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
requestAnimationFrame(syncLoop);
return () => {
window.removeEventListener("mousemove", handleWindowMove);
window.removeEventListener("touchmove", handleWindowMove);
window.removeEventListener("mouseup", handleWindowUp);
window.removeEventListener("touchend", handleWindowUp);
vid.removeEventListener("loadedmetadata", onMetaLoaded);
controlBar.remove();
};
};
function createComparisonViewer(baseSrc) {
const isBaseVideo = isVideoFormat(baseSrc);
// Mount the viewer overlay container directly inside ComfyUI's canvas parent.
// This allows menus/sidebars outside the canvas area to naturally render on top.
const canvasEl = document.querySelector("#graph-canvas, canvas");
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
// Ensure the canvas container is positioned relatively so our overlay stretches accurately
if (targetContainer && targetContainer !== document.body) {
targetContainer.style.position = "relative";
}
const container = document.createElement("div");
container.className = "comfy-sidebar-comparison-overlay";
Object.assign(container.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
zIndex: "10", // Above base LGraph grid but strictly below other UI components
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
});
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", display: "flex", gap: "16px",
zIndex: "30", color: "#aaa", fontSize: "12px", fontFamily: "sans-serif",
pointerEvents: "none"
});
const infoText = document.createElement("span");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Reference loaded. Click another card image in the sidebar to compare.";
header.appendChild(infoText);
container.appendChild(header);
// Close button
const closeBtn = document.createElement("span");
closeBtn.className = "pi pi-times";
closeBtn.title = "Close Comparison (Esc)";
Object.assign(closeBtn.style, {
position: "absolute", top: "16px", right: "24px", zIndex: "30",
cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease"
});
closeBtn.onmouseenter = () => closeBtn.style.color = "#fff";
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
container.appendChild(closeBtn);
// Workspace wrapper holding both elements aligned pixel-perfectly
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
maxWidth: "85%", maxHeight: "85%"
});
container.appendChild(wrapper);
// Close strictly on clicking empty background space of the overlay backdrop
container.onclick = (e) => {
if (e.target === container) {
destroy();
}
};
// Media A (Base reference - Left. Created unmuted to enable audio playback)
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
// Media B (Comparison target - Right)
let mediaB = null;
// Draggable Split Slider Handle
const slider = document.createElement("div");
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
width: "2px", background: "#fff", cursor: "ew-resize",
zIndex: "25", display: "none", pointerEvents: "auto",
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
});
const sliderButton = document.createElement("div");
Object.assign(sliderButton.style, {
position: "absolute", top: "50%", left: "50%",
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
borderRadius: "50%", background: "#fff", color: "#333",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto",
userSelect: "none", webkitUserSelect: "none"
});
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
let splitRatio = 50; // Percentage
let isDragging = false;
let destroyVideoPlaybackFn = null;
const updateSliderPosition = (percent) => {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
if (mediaB) {
// Symmetrical split comparison layout: Image A on Left, Image B on Right
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
}
};
const startDrag = (e) => {
e.preventDefault();
e.stopPropagation();
isDragging = true;
// Symmetrical Click Teleportation: Snaps split-line directly to mouse coordinates
const rect = wrapper.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
};
const doDrag = (e) => {
if (!isDragging) return;
const rect = wrapper.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
};
const endDrag = () => { isDragging = false; };
// Allows dragging/clicking from any region of the wrapper
wrapper.addEventListener("mousedown", startDrag);
wrapper.addEventListener("touchstart", startDrag);
window.addEventListener("mousemove", doDrag);
window.addEventListener("touchmove", doDrag);
window.addEventListener("mouseup", endDrag);
window.addEventListener("touchend", endDrag);
const destroy = () => {
window.removeEventListener("mousemove", doDrag);
window.removeEventListener("touchmove", doDrag);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
document.removeEventListener("keydown", handleKeys);
videoSyncActive = false;
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
container.remove();
activeComparisonViewer = null;
};
closeBtn.onclick = destroy;
const handleKeys = (e) => {
if (e.key === "Escape") destroy();
if (e.key === " " && mediaB) {
e.preventDefault();
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
};
document.addEventListener("keydown", handleKeys);
targetContainer.appendChild(container);
// Initialize single-video playbacks if the base output is an MP4
if (isBaseVideo) {
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
}
return {
loadTarget(targetSrc) {
const isTargetVideo = isVideoFormat(targetSrc);
if (isBaseVideo) {
// If in Video Playback Mode: clicking another item replaces the player or opens image comparison
destroy();
showFullscreenPreview([targetSrc]);
return;
}
// If in Image Comparison Mode: ignore video targets ("images compare only to other images")
if (isTargetVideo) {
return;
}
if (mediaB) mediaB.remove();
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
wrapper.appendChild(mediaB);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Click other card images to update target | Esc to close.";
updateSliderPosition(50);
}
};
}
export function showFullscreenPreview(imgSrcs) {
if (!imgSrcs || imgSrcs.length === 0) return;
// If the comparison overlay is already open, dynamically load the target
if (activeComparisonViewer) {
activeComparisonViewer.loadTarget(imgSrcs[0]);
return;
}
// Otherwise, open a fresh side-by-side comparison workspace
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
}
+32
View File
@@ -0,0 +1,32 @@
export const PromptStatus = Object.freeze({
PENDING: "pending",
ACTIVE: "active",
COMPLETED: "completed",
CANCELLED: "cancelled",
ERROR: "error"
});
export const StoreEvents = Object.freeze({
PROMPT_ADDED: "prompt:added",
PROMPT_UPDATED: "prompt:updated",
PROMPT_DELETED: "prompt:deleted",
PROMPTS_CLEARED: "prompts:cleared",
ACTIVE_PROMPT_CHANGED: "activePrompt:changed",
SEARCH_CHANGED: "search:changed",
SUBMENU_CHANGED: "submenu:changed",
QUEUE_SYNCED: "queue:synced"
});
export const SettingIds = Object.freeze({
GRID_COLUMNS_THRESHOLD: "Comfy Sidebar.Grid Columns Threshold",
SHOW_PENDING_COUNT_ONLY: "Comfy Sidebar.Show Pending Count Only",
SHOW_WORKING_NODE_NAME: "Comfy Sidebar.Show Working Node Name",
AUTO_CLEAR_INTERRUPTED: "Comfy Sidebar.Auto Clear Interrupted",
OVERRIDE_STOCK_HISTORY: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
HIDE_GRAPH_BUTTON: "Comfy Sidebar.Hide Junk.Graph Button",
CLASSIC_LAYOUT: "Comfy Sidebar.Comfy Layout",
MAX_HISTORY_ITEMS: "Comfy.Queue.MaxHistoryItems",
DOCK_QUEUE: "Comfy.Queue.QPOV2",
SHOW_PROGRESS_BAR: "Comfy.Queue.ShowRunProgressBar",
TAB_POSITION: "Comfy.Workflow.WorkflowTabsPosition"
});
+87
View File
@@ -0,0 +1,87 @@
import { PromptStatus } from "./constants.js";
const STORAGE_KEY = "comfy_sidebar_prompt_states";
export const PersistenceAdapter = {
load() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return [];
const list = JSON.parse(data);
return Array.isArray(list) ? list : [];
} catch (e) {
console.warn("Comfy Sidebar: Failed to load states from localStorage", e);
return [];
}
},
save(promptsMap) {
try {
const serializable = [];
for (const [pid, state] of promptsMap.entries()) {
const cleanedImages = (state.images || []).map(img => {
if (img && img.url && img.url.startsWith("blob:")) return null;
return img;
}).filter(Boolean);
const includeWorkflow = cleanedImages.length === 0 || state.status !== PromptStatus.COMPLETED;
serializable.push({
pid: String(state.pid),
status: state.status,
images: cleanedImages,
texts: state.texts || [],
nodeOutputs: state.nodeOutputs || {},
nodeTitles: state.nodeTitles || {}, // Always preserved across restarts
workflow: includeWorkflow ? state.workflow : null,
progress: state.progress || 0,
queueNumber: state.queueNumber,
progressText: state.progressText || "",
timestamp: state.timestamp,
activeNodeName: state.activeNodeName || "",
rendered: !!state.rendered,
startTime: state.startTime,
endTime: state.endTime,
duration: state.duration
});
}
if (serializable.length === 0) {
localStorage.removeItem(STORAGE_KEY);
return;
}
serializable.sort((a, b) => a.timestamp - b.timestamp);
while (serializable.length > 0) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
break;
} catch (e) {
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
serializable.shift();
} else {
console.warn("Comfy Sidebar: LocalStorage error", e);
break;
}
}
}
} catch (e) {
console.warn("Comfy Sidebar: Failed to serialize states", e);
}
},
getPruneCandidates(promptsMap, maxItems = 64) {
const finished = Array.from(promptsMap.entries())
.filter(([_, state]) => state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE);
finished.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
if (finished.length > maxItems) {
const deleteCount = finished.length - maxItems;
return finished.slice(0, deleteCount).map(([pid]) => pid);
}
return [];
}
};
+80
View File
@@ -0,0 +1,80 @@
import { PromptStatus } from "./constants.js";
export function createPromptState(pid, initial = {}) {
return {
pid: String(pid),
status: initial.status || PromptStatus.PENDING,
images: Array.isArray(initial.images) ? [...initial.images] : [],
texts: Array.isArray(initial.texts) ? [...initial.texts] : [],
nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {},
nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {},
progress: typeof initial.progress === "number" ? initial.progress : 0,
progressText: initial.progressText || "",
queueNumber: initial.queueNumber || null,
timestamp: typeof initial.timestamp === "number" ? initial.timestamp : Date.now(),
workflow: initial.workflow || null,
activeNodeName: initial.activeNodeName || "",
startTime: initial.startTime || null,
endTime: initial.endTime || null,
duration: typeof initial.duration === "number" ? initial.duration : null,
rendered: !!initial.rendered,
_previewBlobUrl: initial._previewBlobUrl || null,
_oldPreviewBlobUrl: initial._oldPreviewBlobUrl || null
};
}
export function revokePromptBlobs(state) {
if (!state) return;
if (state._previewBlobUrl) {
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
state._previewBlobUrl = null;
}
if (state._oldPreviewBlobUrl) {
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
state._oldPreviewBlobUrl = null;
}
}
export function activatePrompt(state, patch = {}) {
state.status = PromptStatus.ACTIVE;
state.progress = 0;
state.progressText = "Processing...";
state.rendered = false;
state.startTime = state.startTime || Date.now();
state.duration = null;
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);
if (typeof patch.timestamp === "number") state.timestamp = patch.timestamp;
return state;
}
export function updatePromptProgress(state, progress, activeNodeName = null) {
state.progress = Math.max(0, Math.min(100, Math.round(progress)));
if (activeNodeName !== null) {
state.activeNodeName = activeNodeName;
}
return state;
}
export function finalizePrompt(state, targetStatus, patch = {}) {
// 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;
}
if (patch.workflow) state.workflow = patch.workflow;
if (patch.nodeOutputs) state.nodeOutputs = patch.nodeOutputs;
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
if (patch.images && patch.images.length > 0) state.images = patch.images;
if (patch.texts && patch.texts.length > 0) state.texts = patch.texts;
return state;
}
+189
View File
@@ -0,0 +1,189 @@
import { PromptStatus, StoreEvents } from "./constants.js";
import { createPromptState, revokePromptBlobs } from "./promptState.js";
import { PersistenceAdapter } from "./persistence.js";
class Store {
constructor() {
this.prompts = new Map();
this.ui = {
searchQuery: "",
currentlyActivePromptId: null,
activeSubmenuPromptId: null,
activeSubmenuBatchImages: null,
mainQueueScrollTop: null,
sequenceNumber: 0,
sidebarContainer: null,
cardStack: null
};
this.listeners = new Map();
this._saveTimer = null;
this.init();
}
init() {
const saved = PersistenceAdapter.load();
for (const item of saved) {
const state = createPromptState(item.pid, item);
this.prompts.set(String(state.pid), state);
if (state.timestamp > this.ui.sequenceNumber) {
this.ui.sequenceNumber = state.timestamp;
}
}
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (this.listeners.has(event)) {
this.listeners.get(event).delete(callback);
}
}
emit(event, payload) {
if (this.listeners.has(event)) {
for (const callback of this.listeners.get(event)) {
try {
callback(payload);
} catch (err) {
console.error(`Store error during event '${event}':`, err);
}
}
}
}
nextSequence() {
return ++this.ui.sequenceNumber;
}
scheduleSave(delay = 250) {
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
PersistenceAdapter.save(this.prompts);
}, delay);
}
getPrompt(pid) {
return this.prompts.get(String(pid)) || null;
}
hasPrompt(pid) {
return this.prompts.has(String(pid));
}
getAllPrompts() {
return Array.from(this.prompts.values());
}
setPrompt(pid, state) {
const key = String(pid);
const isNew = !this.prompts.has(key);
this.prompts.set(key, state);
this.scheduleSave();
this.emit(isNew ? StoreEvents.PROMPT_ADDED : StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state });
return state;
}
updatePrompt(pid, patch) {
const key = String(pid);
const state = this.prompts.get(key);
if (!state) return null;
Object.assign(state, patch);
this.scheduleSave();
this.emit(StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state, patch });
return state;
}
deletePrompt(pid) {
const key = String(pid);
const state = this.prompts.get(key);
if (state) {
revokePromptBlobs(state);
this.prompts.delete(key);
if (this.ui.currentlyActivePromptId === key) {
this.ui.currentlyActivePromptId = null;
}
if (this.ui.activeSubmenuPromptId === key) {
this.ui.activeSubmenuPromptId = null;
}
this.scheduleSave();
this.emit(StoreEvents.PROMPT_DELETED, { pid: key });
return true;
}
return false;
}
pruneHistory(maxItems = 64) {
const candidates = PersistenceAdapter.getPruneCandidates(this.prompts, maxItems);
for (const pid of candidates) {
this.deletePrompt(pid);
}
}
clearFinished() {
const toDelete = [];
for (const [pid, state] of this.prompts.entries()) {
if (state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE) {
toDelete.push(pid);
}
}
for (const pid of toDelete) {
this.deletePrompt(pid);
}
this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length });
return toDelete;
}
clearCancelledOrFailed() {
const toDelete = [];
for (const [pid, state] of this.prompts.entries()) {
if (state.status === PromptStatus.CANCELLED || state.status === PromptStatus.ERROR) {
toDelete.push(pid);
}
}
for (const pid of toDelete) {
this.deletePrompt(pid);
}
this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length });
return toDelete;
}
setSearchQuery(query) {
this.ui.searchQuery = (query || "").trim();
this.emit(StoreEvents.SEARCH_CHANGED, { query: this.ui.searchQuery });
}
setActivePromptId(pid) {
this.ui.currentlyActivePromptId = pid ? String(pid) : null;
this.emit(StoreEvents.ACTIVE_PROMPT_CHANGED, { pid: this.ui.currentlyActivePromptId });
}
openBatchSubmenu(batchInfo) {
this.ui.activeSubmenuBatchImages = batchInfo;
this.ui.activeSubmenuPromptId = null;
this.emit(StoreEvents.SUBMENU_CHANGED, { type: "batch", data: batchInfo });
}
openOutputsSubmenu(pid) {
this.ui.activeSubmenuPromptId = String(pid);
this.ui.activeSubmenuBatchImages = null;
this.emit(StoreEvents.SUBMENU_CHANGED, { type: "outputs", pid: this.ui.activeSubmenuPromptId });
}
closeSubmenu() {
const hadSubmenu = this.ui.activeSubmenuPromptId !== null || this.ui.activeSubmenuBatchImages !== null;
this.ui.activeSubmenuPromptId = null;
this.ui.activeSubmenuBatchImages = null;
if (hadSubmenu) {
this.emit(StoreEvents.SUBMENU_CHANGED, { type: null });
}
}
}
export const store = new Store();
-103
View File
@@ -1,103 +0,0 @@
import { app } from "/scripts/app.js";
async function uploadDroppedImageToInput(imageObj) {
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
try {
const response = await fetch(src);
const blob = await response.blob();
const file = new File([blob], imageObj.filename || "dropped_image.png", { type: blob.type });
const formData = new FormData();
formData.append("image", file);
formData.append("overwrite", "true");
formData.append("type", "input");
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
const uploadData = await uploadRes.json();
return uploadData.name;
} catch(e) {
console.error("Comfy Sidebar: Failed to copy image to input.", e);
return null;
}
}
export function setupDragAndDrop() {
document.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("drop", async (e) => {
const canvas = app.canvas;
if (!canvas || !canvas.graph) return;
// 1. Check for standard workflow JSON data drop
const jsonStr = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
if (jsonStr) {
try {
const workflow = JSON.parse(jsonStr);
if (workflow && workflow.nodes) {
e.preventDefault();
e.stopPropagation();
if (app.loadGraphData) {
app.loadGraphData(workflow);
} else if (app.handleFile) {
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
await app.handleFile(file);
}
return;
}
} catch (err) {}
}
// 2. Check for native image URL drop
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
if (url) {
try {
const urlObj = new URL(url, window.location.origin);
if (urlObj.pathname === "/view") {
const filename = urlObj.searchParams.get("filename");
const type = urlObj.searchParams.get("type") || "output";
const subfolder = urlObj.searchParams.get("subfolder") || "";
if (filename) {
e.preventDefault();
e.stopPropagation();
let targetNode = null;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
} else {
const rect = canvas.canvas.getBoundingClientRect();
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
}
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
// Dropped on a node with image input: Copy/Upload image to inputs
const widget = targetNode.widgets.find(w => w.name === "image");
if (widget) {
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
if (newFilename) {
widget.value = newFilename;
if (widget.callback) widget.callback(widget.value);
targetNode.imgs = null;
app.graph.setDirtyCanvas(true, true);
}
}
} else {
// Dropped on empty canvas: Load workflow natively
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
const res = await fetch(src);
const blob = await res.blob();
const file = new File([blob], filename || "workflow.png", { type: blob.type });
if (app.handleFile) await app.handleFile(file);
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
}
}
}
} catch (err) {
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
}
}
}, true);
}
-408
View File
@@ -1,408 +0,0 @@
// layout.js - Fully Fixed & Renamed to 'Hide Junk'
import { app } from "/scripts/app.js";
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
position: fixed !important;
top: -5px !important;
right: 4px !important;
left: auto !important;
transform: none !important;
z-index: 1010 !important;
/* Nuke the background rectangle, border, and shadows */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
}
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 150px !important;
padding-right: 580px !important;
}
/* Medium desktop screens (between 1200px and 1599px width) */
@media (max-width: 1599px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 100px !important;
padding-right: 420px !important;
}
}
/* Standard laptops / small monitors (between 900px and 1199px width) */
@media (max-width: 1199px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 40px !important;
padding-right: 280px !important;
}
}
/* Very narrow or heavily zoomed displays (< 900px width) */
@media (max-width: 899px) {
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 10px !important;
padding-right: 180px !important;
}
}
`;
export function applyClassicLayout(enable) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
} else {
if (styleEl) styleEl.remove();
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
/* --- Properties Panel Toggle Corrections --- */
let savedButtonData = null;
let domObserver = null;
let syncScheduled = false;
function findOriginalButton() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
if (icon) {
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return btn;
}
}
return null;
}
function findTopbarContainer() {
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalButton();
if (orig && orig.parentNode) return orig.parentNode;
const buttons = document.querySelectorAll("button, .p-button");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("run") || title.includes("queue") || title.includes("manager")) {
if (btn.parentNode) return btn.parentNode;
}
}
return null;
}
function isPropertiesPanelOpen() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
return !!(icon && icon.className.includes("lucide--panel-left"));
}
function findActiveQueueIndicator() {
const container = findTopbarContainer();
if (!container) return null;
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const text = (btn.textContent || "").toLowerCase();
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
return btn;
}
}
return null;
}
function updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
// Direct mapping from tab settings to their unique class-based HTML icon tags
const tabIconSelectors = {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"NodesMap": '[class*="pi-sitemap"]',
"Templates": '[class*="comfy--template"]'
};
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
// Read from renamed "Hide Junk" path
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
const icon = sidebar.querySelector(selector);
if (icon) {
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
if (shouldHide) {
if (tabBtn.style.display !== "none") {
tabBtn.style.setProperty("display", "none", "important");
}
} else {
if (tabBtn.style.display === "none") {
tabBtn.style.removeProperty("display");
}
}
}
});
// Handle "Override Stock Job History Tab" using its renamed setting path
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
if (historyIcon) {
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
if (hideStockHistory) {
if (historyBtn.style.display !== "none") {
historyBtn.style.setProperty("display", "none", "important");
}
} else {
if (historyBtn.style.display === "none") {
historyBtn.style.removeProperty("display");
}
}
}
}
function findGraphButton() {
// 1. Target elements with Tailwind classes, but IGNORE those inside modal settings/sidebars
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
for (const el of elements) {
// Filter out dialogs, sidebar overlays, settings drawers, and modals
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
continue;
}
return el;
}
// 2. Same fallback logic, excluding modals
const fallbacks = document.querySelectorAll('button, .bg-secondary-background');
for (const el of fallbacks) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
continue;
}
const text = (el.textContent || el.getAttribute("title") || "").toLowerCase();
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
return el;
}
}
return null;
}
export function syncClassicLayout() {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
// 1. Handle "Hide Graph Button" Toggle under the renamed "Hide Junk" path
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
const graphBtn = findGraphButton();
if (graphBtn) {
if (hideGraphBtn) {
if (graphBtn.style.display !== "none") {
graphBtn.style.setProperty("display", "none", "important");
}
} else {
if (graphBtn.style.display === "none") {
graphBtn.style.removeProperty("display");
}
}
}
// 2. Exit early and clean up overrides if Classic Topbar layout is off
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalButton();
if (originalBtn && originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
}
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (customBtn) {
customBtn.remove();
}
const indicator = findActiveQueueIndicator();
if (indicator && indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
updateSidebarTabsVisibility();
return;
}
// 3. Handle Active Layout elements synchronization
const originalBtn = findOriginalButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
if (originalBtn) {
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
}
savedButtonData = {
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
innerHTML: originalBtn.innerHTML,
tagName: originalBtn.tagName,
attributes: Array.from(originalBtn.attributes).map(attr => ({
name: attr.name,
value: attr.value
}))
};
}
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = savedButtonData.innerHTML;
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
Object.assign(customBtn.style, {
position: "relative",
zIndex: "1000",
margin: "0",
boxSizing: "border-box",
opacity: "1",
pointerEvents: "auto",
display: "inline-flex"
});
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalButton();
if (nativeBtn) nativeBtn.click();
});
container.appendChild(customBtn);
}
if (customBtn) {
customBtn.classList.toggle("comfy-panel-open", openState);
customBtn.style.display = "inline-flex";
}
// 4. Handle "0 active" indicator visibility based on history tab settings
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const indicator = findActiveQueueIndicator();
if (indicator) {
if (hideQueueIndicator) {
if (indicator.style.display !== "none") {
indicator.style.setProperty("display", "none", "important");
}
} else {
if (indicator.style.display === "none") {
indicator.style.removeProperty("display");
}
}
}
updateSidebarTabsVisibility();
}
export function setupPropertiesPanelToggleFix() {
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
const style = document.createElement("style");
style.id = "comfy-sidebar-layout-fix-styles";
style.textContent = `
.comfy-sidebar-hide-original-properties-btn {
display: none !important;
}
.comfy-sidebar-custom-properties-toggle {
order: 99999 !important;
margin-left: -8px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
order: 99999 !important;
margin-left: 0px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
`;
document.head.appendChild(style);
}
if (domObserver) {
domObserver.disconnect();
}
syncClassicLayout();
domObserver = new MutationObserver((mutations) => {
let shouldSync = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
// Ensure mutation is on an actual Element node (nodeType 1) and not just text node updates
const hasElementMutation = Array.from(mutation.addedNodes).some(node => node.nodeType === 1) ||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1);
if (!hasElementMutation) continue;
// Ignore mutations on our own components to avoid feedback loops
const isOurNode = Array.from(mutation.addedNodes).some(node =>
node.classList?.contains?.("comfy-sidebar-custom-properties-toggle") ||
node.id === "comfy-sidebar-layout-fix-styles"
);
if (isOurNode) continue;
shouldSync = true;
break;
}
}
if (shouldSync && !syncScheduled) {
syncScheduled = true;
requestAnimationFrame(() => {
syncClassicLayout();
syncScheduled = false;
});
}
});
domObserver.observe(document.body, {
childList: true,
subtree: true
});
}
-231
View File
@@ -1,231 +0,0 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage } from "./state.js";
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
export let renderDOMFn = () => {};
export let updateSidebarBadgeFn = () => {};
export function setUIDependencies(renderFn, badgeFn) {
renderDOMFn = renderFn;
updateSidebarBadgeFn = badgeFn;
}
export async function syncQueue() {
try {
const q = await api.getQueue();
const runningList = q.Running || q.queue_running || [];
const pendingList = q.Pending || q.queue_pending || [];
const pendingIds = new Set();
const normalizedPending = pendingList.map((p, idx) => {
let pid = null, seq = idx;
if (Array.isArray(p)) {
seq = typeof p[0] === 'number' ? p[0] : idx;
pid = p[1];
} else if (p && typeof p === "object") {
pid = p.prompt_id || p.id || p.uuid;
seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx);
}
return { pid, seq, original: p };
});
normalizedPending.sort((a, b) => a.seq - b.seq);
normalizedPending.forEach((item, index) => {
const pid = item.pid;
const number = index + 1;
if (pid) {
pendingIds.add(pid);
if (!promptStates.has(pid)) {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "pending", images: [], progress: 0, queueNumber: number,
progressText: `Pending... (#${number})`, timestamp: State.globalOrderCounter,
workflow: app.graph.serialize()
});
} else {
const st = promptStates.get(pid);
if (st.status === "pending") {
st.queueNumber = number;
st.progressText = `Pending... (#${number})`;
}
}
}
});
for (const [pid, state] of promptStates.entries()) {
if (state.status === "pending" && !pendingIds.has(pid)) promptStates.delete(pid);
}
updateSidebarBadgeFn(pendingIds.size + (runningList.length > 0 ? 1 : 0));
renderDOMFn();
} catch (err) {
console.error("Comfy Sidebar: Failed to sync queue state", err);
}
}
const concludeRun = async (pid, statusStr) => {
if (!pid || !promptStates.has(pid)) return;
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
const st = promptStates.get(pid);
st.status = statusStr;
st.progressText = "";
st.rendered = false;
st.endTime = Date.now();
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
try {
const res = await fetch(`/history/${pid}`);
const hItem = await res.json();
if (hItem && hItem[pid]) {
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
st.nodeOutputs = hItem[pid].outputs; // Store full outputs dictionary!
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs);
st.texts = findTextsInOutputs(hItem[pid].outputs);
}
} catch (err) {}
pruneHistory(app);
syncQueue();
};
export function setupApiListeners() {
api.addEventListener("status", syncQueue);
api.addEventListener("execution_start", (e) => {
// Auto-sweep cancelled and errored jobs if setting is enabled
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
const toDelete = [];
for (const [p, s] of promptStates.entries()) {
if (s.status === "cancelled" || s.status === "error") {
toDelete.push(p);
promptStates.delete(p);
}
}
if (toDelete.length > 0) {
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
}
}
const pid = e.detail.prompt_id;
State.currentlyActivePromptId = pid;
const activeWorkspaceWorkflow = app.graph.serialize();
if (promptStates.has(pid)) {
const st = promptStates.get(pid);
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
st.rendered = false; st.startTime = Date.now(); st.duration = null;
} else {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "active", images: [], progress: 0,
progressText: "Sampling...", timestamp: State.globalOrderCounter,
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
renderDOMFn(); // Render immediately to prevent pop-in delay!
syncQueue();
});
api.addEventListener("progress", (e) => {
const pid = e.detail.prompt_id;
if (pid && promptStates.has(pid)) {
promptStates.get(pid).progress = Math.round((e.detail.value / e.detail.max) * 100);
renderDOMFn();
}
});
api.addEventListener("executing", (e) => {
const nodeId = e.detail;
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
const st = promptStates.get(State.currentlyActivePromptId);
if (nodeId) {
const node = app.graph.getNodeById(nodeId);
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
} else {
st.activeNodeName = "Finishing...";
}
renderDOMFn();
}
});
api.addEventListener("b_preview", (e) => {
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
if (activeTasks.length > 0) {
const st = activeTasks[0];
if (st._previewBlobUrl) try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
st._previewBlobUrl = URL.createObjectURL(e.detail);
st.images = [{ url: st._previewBlobUrl }];
renderDOMFn();
}
});
api.addEventListener("executed", (e) => {
if (promptStates.has(e.detail.prompt_id)) {
const st = promptStates.get(e.detail.prompt_id);
const finalImgs = findImagesInOutputs({ output: e.detail.output });
if (finalImgs.length > 0) {
// Overwrite with latest images to avoid accumulation across sequential nodes
st.images = finalImgs;
}
const finalTexts = findTextsInOutputs({ output: e.detail.output });
if (finalTexts.length > 0) {
// Overwrite with latest texts to avoid clearing previously set text outputs
st.texts = finalTexts;
}
// Dynamically capture each executed node's individual outputs
if (!st.nodeOutputs) st.nodeOutputs = {};
st.nodeOutputs[e.detail.node] = e.detail.output;
renderDOMFn();
}
});
api.addEventListener("execution_success", (e) => concludeRun(e.detail.prompt_id, "completed"));
api.addEventListener("execution_error", (e) => concludeRun(e.detail.prompt_id, "error"));
api.addEventListener("execution_interrupted", () => {
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled"));
syncQueue();
});
}
export async function initSessionAndHistory() {
let backendSessionId = null;
try {
const res = await fetch("/classic-sidebar/session");
const data = await res.json();
backendSessionId = data.session_id;
} catch (err) {
console.warn("Comfy Sidebar: Custom session endpoint not found. Queue persistence will fall back to cache.");
}
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
if (backendSessionId && backendSessionId === storedSessionId) {
loadStatesFromLocalStorage();
} else {
localStorage.removeItem("comfy_sidebar_prompt_states");
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
}
const historyData = await api.getHistory();
const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b));
ids.forEach(id => {
if (promptStates.has(id)) return;
const images = findImagesInOutputs(historyData[id].outputs);
const texts = findTextsInOutputs(historyData[id].outputs);
if (images.length === 0 && texts.length === 0) return;
State.globalOrderCounter++;
promptStates.set(id, {
pid: id, status: "completed", images, texts,
nodeOutputs: historyData[id].outputs, // Store full outputs dictionary!
workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null,
progressText: "", timestamp: State.globalOrderCounter, rendered: true
});
});
pruneHistory(app);
await syncQueue();
}
+186
View File
@@ -0,0 +1,186 @@
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { store } from "../core/store.js";
import { PromptStatus } from "../core/constants.js";
import { createPromptState, activatePrompt, updatePromptProgress } from "../core/promptState.js";
import { syncQueue, concludeRun, initSessionAndHistory, clearCancelledOrFailed } from "./queueService.js";
import { findImagesInOutputs, findTextsInOutputs } from "../utils/utils.js";
import { updateActiveComparisonPreview, isRuntimePreviewEnabled, focusRuntimePreviewTarget, getActiveComparisonViewer, showFullscreenPreview } from "../utils/comparison.js";
export function setupExecutionTracker(onTargetedProgressUpdate) {
const onStatus = () => syncQueue();
const onReconnected = async () => {
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
await initSessionAndHistory();
await syncQueue();
};
const onExecutionStart = (e) => {
const autoClear = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Auto Clear Interrupted") ?? false;
if (autoClear) {
clearCancelledOrFailed();
}
const pid = String(e.detail.prompt_id);
store.setActivePromptId(pid);
const activeWorkspaceWorkflow = app.graph?.serialize ? app.graph.serialize() : null;
const existing = store.getPrompt(pid);
if (existing) {
activatePrompt(existing, { workflow: activeWorkspaceWorkflow });
store.updatePrompt(pid, existing);
} else {
const seq = store.nextSequence();
const newState = createPromptState(pid, {
status: PromptStatus.ACTIVE,
images: [],
progress: 0,
progressText: "Processing...",
timestamp: seq,
workflow: activeWorkspaceWorkflow,
startTime: Date.now()
});
store.setPrompt(pid, newState);
}
if (isRuntimePreviewEnabled()) {
focusRuntimePreviewTarget();
}
syncQueue();
};
const onProgress = (e) => {
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
if (pid && store.hasPrompt(pid)) {
const prompt = store.getPrompt(pid);
const percent = Math.round((e.detail.value / e.detail.max) * 100);
updatePromptProgress(prompt, percent);
store.updatePrompt(pid, prompt);
if (typeof onTargetedProgressUpdate === "function") {
onTargetedProgressUpdate(pid, percent, prompt.activeNodeName);
}
} else if (pid && !store.hasPrompt(pid)) {
syncQueue();
}
};
const onExecuting = (e) => {
const nodeId = e.detail;
const showWorkingNode = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Show Working Node Name") ?? true;
const activePid = store.ui.currentlyActivePromptId;
if (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 {
prompt.activeNodeName = "Finishing...";
}
store.updatePrompt(activePid, prompt);
if (showWorkingNode && typeof onTargetedProgressUpdate === "function") {
onTargetedProgressUpdate(activePid, prompt.progress, prompt.activeNodeName);
}
}
};
const onBPreview = (e) => {
const activeTasks = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE);
if (activeTasks.length > 0) {
const prompt = activeTasks[0];
const newBlobUrl = URL.createObjectURL(e.detail);
prompt._oldPreviewBlobUrl = prompt._previewBlobUrl;
prompt._previewBlobUrl = newBlobUrl;
prompt.images = [{ url: newBlobUrl }];
store.updatePrompt(prompt.pid, prompt);
// 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);
}
}
};
const onExecuted = (e) => {
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
if (pid && store.hasPrompt(pid)) {
const prompt = store.getPrompt(pid);
const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
// 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) {
prompt.texts = nodeTexts;
}
if (!prompt.nodeOutputs) prompt.nodeOutputs = {};
prompt.nodeOutputs[e.detail.node] = e.detail.output;
store.updatePrompt(pid, prompt);
}
};
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, PromptStatus.COMPLETED);
const onExecutionError = (e) => {
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);
}
concludeRun(e.detail.prompt_id, PromptStatus.ERROR);
};
const onExecutionInterrupted = () => {
store.getAllPrompts()
.filter(t => t.status === PromptStatus.ACTIVE)
.forEach(t => concludeRun(t.pid, PromptStatus.CANCELLED));
syncQueue();
};
api.addEventListener("status", onStatus);
api.addEventListener("reconnected", onReconnected);
api.addEventListener("execution_start", onExecutionStart);
api.addEventListener("progress", onProgress);
api.addEventListener("executing", onExecuting);
api.addEventListener("b_preview", onBPreview);
api.addEventListener("executed", onExecuted);
api.addEventListener("execution_success", onExecutionSuccess);
api.addEventListener("execution_error", onExecutionError);
api.addEventListener("execution_interrupted", onExecutionInterrupted);
return () => {
api.removeEventListener("status", onStatus);
api.removeEventListener("reconnected", onReconnected);
api.removeEventListener("execution_start", onExecutionStart);
api.removeEventListener("progress", onProgress);
api.removeEventListener("executing", onExecuting);
api.removeEventListener("b_preview", onBPreview);
api.removeEventListener("executed", onExecuted);
api.removeEventListener("execution_success", onExecutionSuccess);
api.removeEventListener("execution_error", onExecutionError);
api.removeEventListener("execution_interrupted", onExecutionInterrupted);
};
}
+75
View File
@@ -0,0 +1,75 @@
import { parseWorkflow } from "../utils/utils.js";
import { PromptStatus } from "../core/constants.js";
export function normalizeQueueItem(p, defaultIndex = 0) {
let pid = null;
let seq = defaultIndex;
let rawWorkflow = null;
let outputs = {};
if (Array.isArray(p)) {
seq = typeof p[0] === "number" ? p[0] : defaultIndex;
pid = p[1] ? String(p[1]) : null;
const extraData = p[3] || {};
rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
outputs = p[4] || {};
} else if (p && typeof p === "object") {
pid = (p.prompt_id || p.id || p.uuid) ? String(p.prompt_id || p.id || p.uuid) : null;
seq = typeof p.number === "number" ? p.number : (typeof p.prompt_number === "number" ? p.prompt_number : defaultIndex);
const extraData = p.extra_data || {};
rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
outputs = p.outputs || {};
}
return {
pid,
seq,
workflow: parseWorkflow(rawWorkflow),
outputs
};
}
export function normalizeHistoryItem(pidKey, rawData) {
const pid = String(pidKey);
let item = rawData;
if (Array.isArray(item)) {
item = {
prompt: item[0],
outputs: item[1] || {},
status: item[2] || {}
};
} else if (!item || typeof item !== "object") {
item = { outputs: {}, status: {} };
}
const extraData = item.extra_data || item.prompt?.[3] || {};
const rawWf = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
const workflow = parseWorkflow(rawWf);
const outputs = item.outputs || {};
const queueNumber = item.prompt?.[0] ?? 0;
const serverStatus = item.status?.status_str || "completed";
const isInterrupted = serverStatus === "interrupted" ||
(item.status?.messages && JSON.stringify(item.status.messages).toLowerCase().includes("interrupted"));
let status = PromptStatus.COMPLETED;
if (serverStatus === "success" || serverStatus === "completed") {
status = PromptStatus.COMPLETED;
} else if (isInterrupted) {
status = PromptStatus.CANCELLED;
} else if (serverStatus === "error") {
status = PromptStatus.ERROR;
} else {
status = PromptStatus.CANCELLED;
}
return {
pid,
queueNumber,
workflow,
outputs,
status,
serverStatus
};
}
+324
View File
@@ -0,0 +1,324 @@
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { store } from "../core/store.js";
import { PromptStatus, StoreEvents } from "../core/constants.js";
import { createPromptState, activatePrompt, finalizePrompt } from "../core/promptState.js";
import { normalizeQueueItem, normalizeHistoryItem } from "./queueNormalizer.js";
import { findImagesInOutputs, findTextsInOutputs, getPrimaryOutputImages, getNodeTitle } from "../utils/utils.js";
function extractNodeTitles(outputs, workflow, promptGraph = null) {
const titles = {};
if (!outputs) return titles;
for (const nodeId in outputs) {
const title = getNodeTitle(nodeId, workflow, promptGraph);
if (title) {
titles[nodeId] = title;
}
}
return titles;
}
export async function syncQueue() {
try {
const q = await api.getQueue();
const runningList = q.Running || q.queue_running || [];
const pendingList = q.Pending || q.queue_pending || [];
const runningIds = new Set();
const pendingIds = new Set();
runningList.forEach((raw, idx) => {
const item = normalizeQueueItem(raw, idx);
if (!item.pid) return;
runningIds.add(item.pid);
store.setActivePromptId(item.pid);
const existing = store.getPrompt(item.pid);
const activeWorkflow = item.workflow || (app.graph?.serialize?.() || null);
const titles = extractNodeTitles(item.outputs, activeWorkflow);
if (!existing) {
const seq = store.nextSequence();
const images = getPrimaryOutputImages(item.outputs, activeWorkflow);
const texts = findTextsInOutputs(item.outputs, activeWorkflow);
const newState = createPromptState(item.pid, {
status: PromptStatus.ACTIVE,
images,
texts,
nodeOutputs: item.outputs,
nodeTitles: titles,
progress: 0,
progressText: "Processing...",
timestamp: seq,
workflow: activeWorkflow,
startTime: Date.now()
});
store.setPrompt(item.pid, newState);
} else {
if (existing.status !== PromptStatus.ACTIVE) {
activatePrompt(existing, { workflow: activeWorkflow, nodeTitles: titles });
store.updatePrompt(item.pid, existing);
} else {
if (!existing.workflow && activeWorkflow) existing.workflow = activeWorkflow;
if (Object.keys(titles).length > 0) Object.assign(existing.nodeTitles, titles);
store.updatePrompt(item.pid, existing);
}
}
});
const normalizedPending = pendingList
.map((raw, idx) => normalizeQueueItem(raw, idx))
.filter(item => Boolean(item.pid))
.sort((a, b) => a.seq - b.seq);
normalizedPending.forEach((item, index) => {
const number = index + 1;
pendingIds.add(item.pid);
const existing = store.getPrompt(item.pid);
if (!existing) {
const seq = store.nextSequence();
const newState = createPromptState(item.pid, {
status: PromptStatus.PENDING,
images: [],
progress: 0,
queueNumber: number,
progressText: `Pending... (#${number})`,
timestamp: seq,
workflow: app.graph?.serialize ? app.graph.serialize() : null
});
store.setPrompt(item.pid, newState);
} else if (existing.status === PromptStatus.PENDING) {
if (existing.queueNumber !== number) {
store.updatePrompt(item.pid, {
queueNumber: number,
progressText: `Pending... (#${number})`
});
}
}
});
for (const prompt of store.getAllPrompts()) {
if (prompt.status === PromptStatus.PENDING && !pendingIds.has(prompt.pid)) {
store.deletePrompt(prompt.pid);
} else if (prompt.status === PromptStatus.ACTIVE && !runningIds.has(prompt.pid)) {
concludeRun(prompt.pid, PromptStatus.CANCELLED);
}
}
store.emit(StoreEvents.QUEUE_SYNCED, {
pendingCount: pendingIds.size,
runningCount: runningIds.size
});
} catch (err) {
console.error("Comfy Sidebar: Failed to sync queue state", err);
}
}
export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED) {
const key = String(pid);
if (!key || !store.hasPrompt(key)) return;
if (store.ui.currentlyActivePromptId === key) {
store.setActivePromptId(null);
}
const state = store.getPrompt(key);
finalizePrompt(state, fallbackStatus);
// 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}`);
const hItem = await res.json();
if (hItem && hItem[key]) {
const norm = normalizeHistoryItem(key, hItem[key]);
state.workflow = norm.workflow || state.workflow;
state.nodeOutputs = norm.outputs || state.nodeOutputs;
state.status = norm.status;
const promptGraph = hItem[key].prompt?.[2] || null;
const titles = extractNodeTitles(state.nodeOutputs, state.workflow, promptGraph);
Object.assign(state.nodeTitles, titles);
const primaryImgs = getPrimaryOutputImages(norm.outputs, state.workflow);
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;
break;
}
} catch (err) {}
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);
syncQueue();
}
export async function initSessionAndHistory() {
try {
const historyData = await api.getHistory();
if (!historyData) return;
const rawEntries = [];
if (Array.isArray(historyData.History)) {
historyData.History.forEach(item => {
if (item && item.prompt_id) {
rawEntries.push({ pid: String(item.prompt_id), data: item });
}
});
} else if (typeof historyData === "object") {
Object.keys(historyData).forEach(pidKey => {
rawEntries.push({ pid: String(pidKey), data: historyData[pidKey] });
});
}
const normalized = rawEntries.map(e => ({
norm: normalizeHistoryItem(e.pid, e.data),
promptGraph: e.data.prompt?.[2] || null
}));
normalized.sort((a, b) => a.norm.queueNumber - b.norm.queueNumber);
normalized.forEach(({ norm: item, promptGraph }) => {
const images = getPrimaryOutputImages(item.outputs, item.workflow);
const texts = findTextsInOutputs(item.outputs, item.workflow);
const titles = extractNodeTitles(item.outputs, item.workflow, promptGraph);
if (images.length === 0 && texts.length === 0 && item.status === PromptStatus.COMPLETED) {
return;
}
const existing = store.getPrompt(item.pid);
if (existing) {
if (existing.status === PromptStatus.ACTIVE || existing.status === PromptStatus.PENDING) {
existing.status = item.status;
existing.images = images;
existing.texts = texts;
existing.nodeOutputs = item.outputs;
existing.workflow = item.workflow || existing.workflow;
Object.assign(existing.nodeTitles, titles);
existing.progressText = "";
existing.rendered = false;
store.updatePrompt(item.pid, existing);
} else {
if (!existing.workflow && item.workflow) existing.workflow = item.workflow;
if ((!existing.images || existing.images.length === 0) && images.length > 0) existing.images = images;
if (!existing.nodeOutputs || Object.keys(existing.nodeOutputs).length === 0) {
existing.nodeOutputs = item.outputs;
}
if (!existing.nodeTitles) existing.nodeTitles = {};
Object.assign(existing.nodeTitles, titles);
if ((!existing.texts || existing.texts.length === 0) && texts && texts.length > 0) existing.texts = texts;
store.updatePrompt(item.pid, existing);
}
return;
}
const seq = store.nextSequence();
const newState = createPromptState(item.pid, {
status: item.status,
images,
texts,
nodeOutputs: item.outputs,
nodeTitles: titles,
workflow: item.workflow,
progressText: "",
timestamp: seq,
rendered: true
});
store.setPrompt(item.pid, newState);
});
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
store.pruneHistory(maxItems);
await syncQueue();
} catch (err) {
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
}
}
export async function cancelPendingTask(pid) {
try {
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [pid] }) });
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to cancel pending task", e);
}
}
export async function cancelAllPending() {
try {
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) });
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to cancel all pending tasks", e);
}
}
export async function interruptActive() {
try {
await api.interrupt();
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to interrupt active run", e);
}
}
export async function deleteHistoryItem(pid) {
try {
store.deletePrompt(pid);
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [pid] }) });
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to delete history item", e);
}
}
export async function clearAllHistory() {
try {
store.clearFinished();
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ clear: true }) });
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to clear all history", e);
}
}
export async function clearCancelledOrFailed() {
try {
const toDelete = store.clearCancelledOrFailed();
if (toDelete.length > 0) {
await api.fetchApi("/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete: toDelete })
});
}
await syncQueue();
} catch (e) {
console.error("Comfy Sidebar: Failed to clear cancelled or failed tasks", e);
}
}
+268 -125
View File
@@ -1,125 +1,268 @@
// sidebar.js - Fully Fixed Real-Time UI Sync (With setTimeout deferred updates)
import { app } from "/scripts/app.js";
import { injectStyles } from "./styles.js";
import { setupDragAndDrop } from "./dragdrop.js";
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
init() {
// Standard Comfy Queue Settings
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
// Add settings to hide individual sidebar tabs under the "Hide Junk" sub-category
// We use setTimeout to let Comfy finish writing the setting value before reading it
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "NodesMap", "Templates"];
sidebarTabs.forEach(tab => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: `Hide Tab: ${tab}`,
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
});
// Group the Stock History Tab override into "Hide Junk"
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
name: "Replaces the stock Job History sidebar with Comfy Queue",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
// Add the toggle setting for Graph Button under "Hide Junk"
app.ui.settings.addSetting({
id: "Comfy Sidebar.Hide Junk.Graph Button",
name: "Hide floating 'Graph' (Workflow/Node Map) button",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => {
if (typeof syncClassicLayout === "function") syncClassicLayout();
}, 0);
}
});
// Add the toggle setting for Classic / Comfy Layout
app.ui.settings.addSetting({
id: "Comfy Sidebar.Comfy Layout",
name: "Places the controls and the open workflow tabs on a single unified top bar",
type: "boolean",
defaultValue: false,
onChange: (value) => {
applyClassicLayout(value);
}
});
},
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
// Apply saved layout configuration on startup
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
applyClassicLayout(isClassicLayoutEnabled);
// Run properties panel toggle correction
setupPropertiesPanelToggleFix();
// Safely resolve circular dependencies using Dependency Injection Hooks
setSyncQueue(syncQueue);
setUIDependencies(renderDOM, updateSidebarBadge);
// 1. Initialize DOM Independent Services
injectStyles();
setupDragAndDrop();
// 2. Initialize UI Hierarchy
const sidebarContainer = setupSidebarUI();
// 3. Connect API Hooks
setupApiListeners();
// 4. Connect Storage and History Sync
await initSessionAndHistory();
// 5. Connect UI Keyboard & Override Hacks
document.addEventListener("keydown", (e) => {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
if (e.key.toLowerCase() === "q") {
e.preventDefault(); e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
}, true);
setInterval(() => applySidebarOverride(), 500);
// 6. Final Registration
app.extensionManager.registerSidebarTab({
id: "classic-comfy-sidebar",
icon: "pi pi-images",
title: "Queue",
tooltip: "Comfy Queue (Q)",
type: "custom",
render: (el) => { el.appendChild(sidebarContainer); }
});
}
});
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";
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;
let cleanupFns = [];
function registerSettings() {
app.ui.settings.addSetting({
id: SettingIds.GRID_COLUMNS_THRESHOLD,
name: "Width Threshold for Queue Columns (px)",
type: "number",
defaultValue: 350
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_PENDING_COUNT_ONLY,
name: "If disabled, each queued job will have a separate individual card",
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.SHOW_WORKING_NODE_NAME,
name: "Shows the name of the node which is currently in the process",
type: "boolean",
defaultValue: true
});
app.ui.settings.addSetting({
id: SettingIds.AUTO_CLEAR_INTERRUPTED,
name: "Auto-clear cancelled & failed jobs on new generation",
type: "boolean",
defaultValue: false
});
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 => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: `Hide Tab: ${tab}`,
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
});
app.ui.settings.addSetting({
id: SettingIds.OVERRIDE_STOCK_HISTORY,
name: "Replace the stock Job History sidebar with Comfy Queue",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized && app.ui?.settings) {
syncStockHistoryAndProgressSettings(value);
}
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.HIDE_GRAPH_BUTTON,
name: "Hide floating 'Graph' (Workflow/Node Map) button",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.CLASSIC_LAYOUT,
name: "Places the controls and the open workflow tabs on a single unified top bar",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized) {
applyClassicLayout(value, true);
}
}
});
}
function setupKeydownShortcuts() {
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
if (activeEl && (
activeEl.tagName === "INPUT" ||
activeEl.tagName === "TEXTAREA" ||
activeEl.isContentEditable ||
activeEl.tagName === "SELECT"
)) return;
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleIgnoreActiveNode(() => renderSidebar());
}
// 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);
}
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
init() {
registerSettings();
},
commands: [
{
id: "ComfySidebar.ToggleSidebar",
label: "Toggle Comfy Queue Sidebar",
function: () => {
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
},
{
id: "ComfySidebar.ToggleIgnoreNode",
label: "Toggle Ignore Selected Node(s) in Queue",
function: () => toggleIgnoreActiveNode(() => renderSidebar())
},
{
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";
syncAllNodeBadges();
}
},
afterConfigureGraph() {
requestAnimationFrame(() => {
syncAllNodeBadges();
renderSidebar();
});
},
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
const isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
applyClassicLayout(isClassicLayoutEnabled, false);
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false;
if (isHistoryOverrideEnabled) {
syncStockHistoryAndProgressSettings(true);
}
isInitialized = true;
setupPropertiesPanelToggleFix();
injectStyles();
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupVueNodeObserver());
// Setup execution tracker with targeted fast-path update for active card
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
const cardObj = cardPool.get(pid);
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, state);
} else {
renderSidebar();
}
}));
const sidebarContainer = setupSidebarView();
await initSessionAndHistory();
setupKeydownShortcuts();
syncAllNodeBadges();
app.extensionManager.registerSidebarTab({
id: "classic-comfy-sidebar",
icon: "pi pi-images",
title: "Queue",
tooltip: "Comfy Queue (Q)",
type: "custom",
render: (el) => { el.appendChild(sidebarContainer); }
});
},
destroy() {
for (const fn of cleanupFns.splice(0)) {
try { if (typeof fn === "function") fn(); } catch (e) {}
}
destroyLayoutFix();
teardownSidebarView();
if (activeKeydownHandler) {
document.removeEventListener("keydown", activeKeydownHandler, true);
activeKeydownHandler = null;
}
if (app.extensionManager?.unregisterSidebarTab) {
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
}
}
});
-69
View File
@@ -1,69 +0,0 @@
export const promptStates = new Map();
export const cardElements = new Map();
// Global shared variables
export const State = {
currentSearchQuery: "",
globalOrderCounter: 0,
sidebarContainer: null,
cardStack: null,
currentlyActivePromptId: null,
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
activeSubmenuBatchImages: null // Active batch images explorer pointer
};
export function pruneHistory(app) {
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
const tasks = Array.from(promptStates.entries())
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
tasks.sort((a, b) => a[1].timestamp - b[1].timestamp);
if (tasks.length > maxItems) {
const deleteCount = tasks.length - maxItems;
for (let i = 0; i < deleteCount; i++) {
const [pid] = tasks[i];
promptStates.delete(pid);
cardElements.delete(pid);
}
}
}
export function saveStatesToLocalStorage() {
try {
const serializable = [];
for (const [pid, state] of promptStates.entries()) {
const cleanedImages = (state.images || []).map(img => {
if (img.url && img.url.startsWith("blob:")) return null; // Skip blob URLs
return img;
}).filter(Boolean);
serializable.push({
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
nodeOutputs: state.nodeOutputs, // Persist node level outputs
workflow: state.workflow, progress: state.progress || 0, queueNumber: state.queueNumber,
progressText: state.progressText || "", timestamp: state.timestamp,
activeNodeName: state.activeNodeName || "", rendered: state.rendered || false,
startTime: state.startTime, endTime: state.endTime, duration: state.duration
});
}
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
} catch (e) {
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
}
}
export function loadStatesFromLocalStorage() {
try {
const data = localStorage.getItem("comfy_sidebar_prompt_states");
if (data) {
const list = JSON.parse(data);
list.forEach(state => {
promptStates.set(state.pid, state);
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
});
}
} catch (e) {
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
}
}
-60
View File
@@ -1,60 +0,0 @@
// styles.js
export function injectStyles() {
const style = document.createElement("style");
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
border: 2px solid var(--border-color, #333);
color: var(--comfy-input-color, var(--fg-color, #eee));
}
.comfy-sidebar-card:hover {
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
transition: border-color 0.2s, background-color 0.2s;
}
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
display: none !important;
}
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
display: flex !important;
}
.comfy-sidebar-card-timer {
position: absolute; top: 6px; left: 8px; font-size: 10px;
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
}
/* --- Custom Queue Cancel Button to Mimic ComfyUI Native Destructive Button --- */
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important; /* Native deep red */
color: #e2e8f0 !important; /* Native slate white icon */
font-size: 11px !important; /* Muted native cross size */
border-radius: 6px !important; /* Native corner rounding */
cursor: pointer !important;
transition: background-color 0.15s ease, color 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
background-color: #991b1b !important; /* Slightly brighter red on hover */
color: #ffffff !important;
}
`;
document.head.appendChild(style);
}
-1093
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import { isAudioViewerOpen as checkAudioViewerOpen } from "../utils/comparison.js";
let currentlyPlayingAudio = null;
export function stopAllAudioPlayback() {
if (currentlyPlayingAudio) {
currentlyPlayingAudio.pause();
currentlyPlayingAudio.currentTime = 0;
if (typeof currentlyPlayingAudio._onResetUI === "function") {
currentlyPlayingAudio._onResetUI();
}
currentlyPlayingAudio = null;
}
}
export function setCurrentlyPlayingAudio(audio) {
stopAllAudioPlayback();
currentlyPlayingAudio = audio;
}
export function getCurrentlyPlayingAudio() {
return currentlyPlayingAudio;
}
export function isAudioViewerOpen() {
return checkAudioViewerOpen();
}
+703
View File
@@ -0,0 +1,703 @@
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, 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();
export function resetAllCardHoverStates() {
for (const cardObj of cardPool.values()) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
}
}
export function syncCardButtonVisibility(cardObj, state) {
if (!cardObj) return;
const isPendingOrActive = state.status === PromptStatus.PENDING || state.status === PromptStatus.ACTIVE;
if (isPendingOrActive) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
return;
} else {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display");
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
}
const 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;
const isPng = currentImg && (currentImg.filename || "").toLowerCase().endsWith(".png");
if (cardObj.btnImg) {
if (hasRealImages) {
cardObj.btnImg.style.removeProperty("display");
cardObj.btnImg.style.display = "inline-flex";
cardObj.btnImg.onclick = (ev) => {
ev.stopPropagation();
state.images.forEach(img => {
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
a.download = img.filename || "output";
a.click();
});
};
} else {
cardObj.btnImg.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnCopy) {
const isCopyableImage = hasRealImages && currentImg && isImageFormat(currentImg.filename || currentImg.url);
if (isCopyableImage) {
cardObj.btnCopy.style.removeProperty("display");
cardObj.btnCopy.style.display = "inline-flex";
cardObj.btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
const success = await copyImageToClipboard(src);
if (success) {
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
cardObj.btnCopy.style.color = "#4ade80";
setTimeout(() => {
cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
cardObj.btnCopy.style.color = "";
}, 1500);
}
};
} else {
cardObj.btnCopy.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnJson) {
if (state.workflow || isPng) {
cardObj.btnJson.style.removeProperty("display");
cardObj.btnJson.style.display = "inline-flex";
cardObj.btnJson.onclick = async (ev) => {
ev.stopPropagation();
let wf = state.workflow;
if (!wf && currentImg) {
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
wf = await extractWorkflowFromPng(src);
if (wf) state.workflow = wf;
}
if (!wf) {
alert("No workflow metadata found in this file.");
return;
}
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflow_${state.pid || "comfy"}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
};
} else {
cardObj.btnJson.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnDel) {
cardObj.btnDel.style.removeProperty("display");
cardObj.btnDel.style.display = "inline-flex";
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) || 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);
};
} else {
cardObj.btnFocus.style.setProperty("display", "none", "important");
}
}
if (cardObj.leftHoverBtn) {
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
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 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) ||
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
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(liveState.pid);
};
}
}
}
export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true, state = null) {
if (!cardObj) return;
if (cardObj.progressBar) {
cardObj.progressBar.style.width = `${progress}%`;
}
if (cardObj.statusText && showWorkingNode) {
cardObj.statusText.style.display = "block";
const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? "Finishing..." : `[${activeNodeName}]`) : "Processing...";
cardObj.statusText.textContent = `${nodeText} ${progress}%`;
}
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 = {}) {
let cardObj = cardPool.get(state.pid);
if (cardObj) return cardObj;
const card = document.createElement("div");
card.id = `card-${state.pid}`;
card.className = `comfy-sidebar-card ${state.status || "completed"}`;
card.style.position = "relative";
const timerEl = document.createElement("div");
timerEl.className = "comfy-sidebar-card-timer";
const cancelX = document.createElement("span");
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
Object.assign(cancelX.style, {
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "25"
});
const sBadge = document.createElement("div");
sBadge.className = "comfy-sidebar-status-badge";
Object.assign(sBadge.style, {
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");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const pt = document.createElement("div");
Object.assign(pt.style, {
width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px",
overflow: "hidden", display: "none"
});
const pb = document.createElement("div");
Object.assign(pb.style, {
width: "0%", height: "100%", background: "#3b82f6", transition: "width 0.1s linear"
});
pt.appendChild(pb);
const statusText = document.createElement("div");
Object.assign(statusText.style, {
fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px",
display: "none", fontWeight: "bold"
});
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
btnCopy.style.display = "none";
const btnJson = document.createElement("span");
btnJson.className = "pi pi-file comfy-sidebar-card-action-btn";
btnJson.title = "Download JSON";
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
const leftHoverBtn = document.createElement("span");
leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn";
leftHoverBtn.title = "View all intermediate outputs";
hoverPanel.append(btnImg, btnJson, btnDel);
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
// 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,
timerEl,
statusBadge: sBadge,
dimEl,
grid,
placeholder: p,
progressContainer: pt,
progressBar: pb,
cancelBtn: cancelX,
hoverPanel,
leftHoverPanel,
btnFocus,
leftHoverBtn,
btnImg,
btnCopy,
btnJson,
btnDel,
btnDelLabel,
canDeleteFromDisk: true,
statusText,
firstImgElement: null,
lastImagesSignature: "",
currentImageIndex: 0,
_isCheckingIntermediates: false
};
card.addEventListener("mouseenter", () => {
if (store.hasPrompt(state.pid)) {
const liveState = store.getPrompt(state.pid);
syncCardButtonVisibility(cardObj, liveState);
}
});
card.addEventListener("dragstart", (e) => {
const liveState = store.getPrompt(state.pid) || state;
const isUnfinished = liveState.status && liveState.status !== PromptStatus.COMPLETED;
const hasNoImages = !liveState.images || liveState.images.length === 0;
if (liveState.workflow && (hasNoImages || isUnfinished)) {
if (cardObj.firstImgElement) {
try { e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15); } catch(err){}
}
const jsonStr = JSON.stringify(liveState.workflow, null, 2);
const filename = `workflow_${liveState.pid}.json`;
const base64Data = btoa(unescape(encodeURIComponent(jsonStr)));
try {
e.dataTransfer.setData("DownloadURL", `application/json:${filename}:data:application/json;base64,${base64Data}`);
e.dataTransfer.setData("application/json", jsonStr);
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
}
});
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
const resetDeleteBtn = () => {
isDeletePending = false;
isDiskDelete = false;
cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
cardObj.btnDel.title = "Delete Card (Ctrl+Click to delete from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing card";
deleteTimeout = setTimeout(resetDeleteBtn, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDeleteBtn();
const liveState = store.getPrompt(state.pid) || state;
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
}
}
};
(liveState.images || []).forEach(addFile);
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
let deletedCount = 0;
let lastAction = "trashed";
for (const fileItem of filesToDelete) {
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");
}
}
deleteHistoryItem(state.pid);
}
};
cardPool.set(state.pid, cardObj);
return cardObj;
}
export function updateCardDOM(cardObj, state, showPendingSummary = true, showWorkingNode = true) {
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
cardObj.element.style.position = "relative";
const isUnfinished = state.status && state.status !== PromptStatus.COMPLETED;
if (state.images && state.images.length > 0 && !isUnfinished) {
cardObj.element.removeAttribute("draggable");
} else {
cardObj.element.setAttribute("draggable", "true");
}
if (state.status === PromptStatus.ACTIVE) {
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
cardObj.timerEl.style.display = "block";
} else if (state.duration !== undefined && state.duration !== null) {
cardObj.timerEl.textContent = state.duration.toFixed(2) + "s";
cardObj.timerEl.style.display = "block";
} else {
cardObj.timerEl.style.display = "none";
}
if (state.status === PromptStatus.CANCELLED) {
Object.assign(cardObj.statusBadge.style, {
display: "block",
background: "#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",
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();
};
} else {
cardObj.cancelBtn.style.display = "none";
}
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
if (cardObj.lastImagesSignature !== currentImagesSignature) {
if (!state.images || state.images.length === 0) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, state, (st) => {
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
store.openBatchSubmenu({
pid: st.pid,
images: st.images,
workflow: st.workflow,
nodeOutputs: st.nodeOutputs
});
});
}
cardObj.lastImagesSignature = currentImagesSignature;
}
if (state.images.length === 0) {
if (state.texts && state.texts.length > 0) {
const fullText = state.texts.join("\n\n");
cardObj.placeholder.textContent = fullText;
cardObj.placeholder.title = "Click to read full text";
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
cardObj.placeholder.style.cssText = "";
cardObj.placeholder.style.display = "-webkit-box";
cardObj.placeholder.onclick = (e) => {
e.stopPropagation();
stopAllAudioPlayback();
showFullscreenPreview([{ text: fullText, pid: state.pid }], false, state.pid);
};
} else {
cardObj.placeholder.textContent = state.progressText || "No Outputs";
cardObj.placeholder.className = "";
Object.assign(cardObj.placeholder.style, {
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default"
});
cardObj.placeholder.onclick = null;
}
}
if (state.status === PromptStatus.ACTIVE) {
if (showWorkingNode) {
cardObj.statusText.style.display = "block";
const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]`) : "Processing...";
cardObj.statusText.textContent = `${nodeText}${state.progress ? ` ${state.progress}%` : ""}`;
} else {
cardObj.statusText.style.display = "none";
}
cardObj.progressContainer.style.display = "block";
cardObj.progressBar.style.width = `${state.progress || 0}%`;
} else {
cardObj.statusText.style.display = "none";
cardObj.progressContainer.style.display = "none";
}
syncCardButtonVisibility(cardObj, state);
return cardObj.element;
}
+127
View File
@@ -0,0 +1,127 @@
export async function copyImageToClipboard(src) {
try {
const res = await fetch(src);
let blob = await res.blob();
if (blob.type !== "image/png") {
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = src;
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
canvas.getContext("2d").drawImage(img, 0, 0);
blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
}
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return true;
} catch (err) {
console.error("Comfy Sidebar: Failed to copy image to clipboard", err);
return false;
}
}
export async function openFileOrFolder(img) {
if (!img) return;
try {
const res = await fetch("/comfy-sidebar/open-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: img.filename,
subfolder: img.subfolder || "",
type: img.type || "output"
})
});
if (res.ok) return;
} catch (e) {}
const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const a = document.createElement("a");
a.href = src;
a.target = "_blank";
a.download = img.filename || "file";
document.body.appendChild(a);
a.click();
a.remove();
}
import { app } from "/scripts/app.js";
export async function deleteFileOnServer(fileItem) {
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: fileItem.filename,
subfolder: fileItem.subfolder || "",
type: fileItem.type || "output",
permanent: isPermanent
})
});
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 null;
}
}
export function removeImageFromNodeOutputs(nodeOutputs, targetImg) {
if (!nodeOutputs || !targetImg || !targetImg.filename) return;
const scanAndRemove = (obj) => {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const val = obj[key];
if (Array.isArray(val)) {
for (let i = val.length - 1; i >= 0; i--) {
const item = val[i];
if (item && typeof item === "object") {
if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) {
val.splice(i, 1);
} else {
scanAndRemove(item);
}
} else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) {
val.splice(i, 1);
}
}
} else if (typeof val === "object") {
scanAndRemove(val);
}
}
};
for (const nodeId in nodeOutputs) {
scanAndRemove(nodeOutputs[nodeId]);
}
}
export function findNodeIdForImage(state, img) {
if (!state || !state.nodeOutputs || !img) return null;
for (const nodeId in state.nodeOutputs) {
const out = state.nodeOutputs[nodeId];
for (const key in out) {
const val = out[key];
if (Array.isArray(val)) {
if (val.some(i => i && typeof i === "object" && i.filename === img.filename)) {
return nodeId;
}
} else if (val && typeof val === "object" && val.filename === img.filename) {
return nodeId;
}
}
}
return null;
}
+716
View File
@@ -0,0 +1,716 @@
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } 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 render3DCardPreview(cardObj, wrapper, src, img, state) {
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
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 imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || [];
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
const bgImgSrc = lastImageAsset
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
: null;
if (!preview3D) {
wrapper.innerHTML = "";
preview3D = document.createElement("div");
preview3D.className = "comfy-sidebar-3d-wrapper";
if (bgImgSrc) {
const bgImg = document.createElement("img");
bgImg.className = "comfy-sidebar-3d-preview-img";
bgImg.src = bgImgSrc;
bgImg.alt = "3D Thumbnail Preview";
preview3D.appendChild(bgImg);
}
const overlay = document.createElement("div");
overlay.className = "comfy-sidebar-3d-overlay";
const badge = document.createElement("span");
badge.className = "comfy-sidebar-3d-badge";
const ext = (img.filename || src).split('.').pop().toUpperCase();
badge.textContent = ext || "3D";
const icon = document.createElement("span");
icon.className = "pi pi-box comfy-sidebar-3d-icon";
const title = document.createElement("span");
title.className = "comfy-sidebar-3d-title";
title.textContent = img.filename || "3D Model";
overlay.append(badge, icon, title);
preview3D.appendChild(overlay);
wrapper.appendChild(preview3D);
} else {
const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img");
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
bgImg.src = bgImgSrc;
} else if (!bgImg && bgImgSrc) {
const newBgImg = document.createElement("img");
newBgImg.className = "comfy-sidebar-3d-preview-img";
newBgImg.src = bgImgSrc;
newBgImg.alt = "3D Thumbnail Preview";
preview3D.insertBefore(newBgImg, preview3D.firstChild);
}
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
if (title) title.textContent = img.filename || "3D Model";
}
cardObj.firstImgElement = preview3D;
preview3D.onclick = (ev) => {
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl], ev.shiftKey, state?.pid);
};
preview3D.setAttribute("draggable", "true");
preview3D.ondragstart = (e) => {
e.stopPropagation();
const filename = img.filename || "model.glb";
const mimeType = "application/octet-stream";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
const 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 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>`;
if (!previewAudio) {
wrapper.innerHTML = "";
previewAudio = document.createElement("div");
previewAudio.className = "comfy-sidebar-audio-wrapper";
const topRow = document.createElement("div");
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
const title = document.createElement("span");
title.className = "comfy-sidebar-audio-title";
title.textContent = filename;
const badge = document.createElement("span");
badge.className = "comfy-sidebar-audio-badge";
badge.textContent = ext || "AUDIO";
topRow.append(title, badge);
const centerArea = document.createElement("div");
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" });
const soundwave = document.createElement("div");
soundwave.className = "comfy-sidebar-soundwave-container";
soundwave.style.width = "100%";
const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12];
barHeights.forEach((h, i) => {
const bar = document.createElement("div");
bar.className = "comfy-sidebar-soundwave-bar";
bar.style.height = `${h}px`;
bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`;
soundwave.appendChild(bar);
});
const playBtn = document.createElement("button");
Object.assign(playBtn.style, {
position: "absolute", width: "36px", height: "36px", borderRadius: "50%",
background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)",
color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center",
justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5",
transition: "all 0.15s ease", outline: "none"
});
playBtn.innerHTML = playIconSvg;
centerArea.append(soundwave, playBtn);
const bottomRow = document.createElement("div");
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" });
const scrubber = document.createElement("div");
Object.assign(scrubber.style, {
width: "100%", height: "4px", background: "#334155", borderRadius: "2px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" });
scrubberFill.style.borderRadius = "2px";
scrubber.appendChild(scrubberFill);
const timeLabel = document.createElement("div");
Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" });
timeLabel.textContent = "0:00 / 0:00";
bottomRow.append(scrubber, timeLabel);
const audioEl = document.createElement("audio");
audioEl.src = fullUrl;
audioEl.preload = "metadata";
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
const resetAudioUI = () => {
playBtn.innerHTML = playIconSvg;
previewAudio.classList.remove("playing");
scrubberFill.style.width = "0%";
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl._onResetUI = resetAudioUI;
audioEl.onloadedmetadata = () => {
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl.ontimeupdate = () => {
if (audioEl.duration > 0) {
const percent = (audioEl.currentTime / audioEl.duration) * 100;
scrubberFill.style.width = `${percent}%`;
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
}
};
audioEl.onended = () => {
resetAudioUI();
if (getCurrentlyPlayingAudio() === audioEl) stopAllAudioPlayback();
};
const togglePlay = (e) => {
e.stopPropagation();
if (isAudioViewerOpen()) {
stopAllAudioPlayback();
showFullscreenPreview([fullUrl], false, state?.pid);
return;
}
if (audioEl.paused) {
setCurrentlyPlayingAudio(audioEl);
audioEl.play().catch(()=>{});
playBtn.innerHTML = stopIconSvg;
previewAudio.classList.add("playing");
} else {
audioEl.pause();
audioEl.currentTime = 0;
resetAudioUI();
stopAllAudioPlayback();
}
};
playBtn.onclick = togglePlay;
playBtn.onmouseenter = () => {
playBtn.style.background = "#9333ea";
playBtn.style.borderColor = "#c084fc";
playBtn.style.transform = "scale(1.08)";
};
playBtn.onmouseleave = () => {
playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)";
playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)";
playBtn.style.transform = "scale(1.0)";
};
scrubber.onclick = (e) => {
e.stopPropagation();
const rect = scrubber.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
};
previewAudio.append(topRow, centerArea, bottomRow, audioEl);
wrapper.appendChild(previewAudio);
previewAudio.onclick = (e) => {
if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return;
stopAllAudioPlayback();
if (e.ctrlKey || e.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl], false, state?.pid);
};
}
cardObj.firstImgElement = previewAudio;
previewAudio.setAttribute("draggable", "true");
previewAudio.ondragstart = (e) => {
e.stopPropagation();
const mimeType = "audio/wav";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
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 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");
if (!previewFile) {
wrapper.innerHTML = "";
previewFile = document.createElement("div");
previewFile.className = "comfy-sidebar-file-wrapper";
const topRow = document.createElement("div");
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" });
const badge = document.createElement("span");
badge.className = "comfy-sidebar-file-badge";
badge.textContent = ext;
topRow.appendChild(badge);
const centerArea = document.createElement("div");
Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" });
const icon = document.createElement("span");
icon.className = "pi pi-file comfy-sidebar-file-icon";
const title = document.createElement("span");
title.className = "comfy-sidebar-file-title";
title.textContent = filename;
title.title = filename;
centerArea.append(icon, title);
const bottomRow = document.createElement("div");
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
const folderInfo = document.createElement("div");
folderInfo.className = "comfy-sidebar-file-subtext";
folderInfo.innerHTML = `<i class="pi pi-folder" style="font-size: 10px;"></i><span>${folderName}</span>`;
const openHint = document.createElement("div");
openHint.className = "comfy-sidebar-file-subtext";
openHint.innerHTML = `<i class="pi pi-external-link" style="font-size: 9px;"></i><span>Open Folder</span>`;
bottomRow.append(folderInfo, openHint);
previewFile.append(topRow, centerArea, bottomRow);
wrapper.appendChild(previewFile);
} else {
const title = previewFile.querySelector(".comfy-sidebar-file-title");
if (title) { title.textContent = filename; title.title = filename; }
const badge = previewFile.querySelector(".comfy-sidebar-file-badge");
if (badge) badge.textContent = ext;
}
cardObj.firstImgElement = previewFile;
previewFile.onclick = (ev) => {
ev.stopPropagation();
openFileOrFolder(img);
};
previewFile.setAttribute("draggable", "true");
previewFile.ondragstart = (e) => {
e.stopPropagation();
const mimeType = "application/octet-stream";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
export function renderCardImages(cardObj, state, onNavigateBatch) {
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
if (cardObj.currentImageIndex >= state.images.length) {
cardObj.currentImageIndex = 0;
}
const idx = cardObj.currentImageIndex;
const img = state.images[idx];
if (!img) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
return;
}
const 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);
const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes("."));
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
if (!wrapper) {
cardObj.grid.innerHTML = "";
wrapper = document.createElement("div");
wrapper.className = "comfy-sidebar-media-wrapper";
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
cardObj.grid.appendChild(wrapper);
}
let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay");
if (!ctrlOverlay) {
ctrlOverlay = document.createElement("div");
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>Open Location</span>`;
wrapper.appendChild(ctrlOverlay);
}
if (is3D) {
render3DCardPreview(cardObj, wrapper, src, img, state);
return;
}
if (isAudio) {
renderAudioCardPreview(cardObj, wrapper, src, img, state);
return;
}
if (!isImage && !isVideo) {
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
return;
}
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
if (needsRebuild) {
if (mediaEl) mediaEl.remove();
mediaEl = isVideo ? document.createElement("video") : document.createElement("img");
Object.assign(mediaEl.style, {
width: "100%",
borderRadius: "2px",
display: "block",
cursor: "zoom-in",
webkitUserDrag: "element",
zIndex: "1"
});
wrapper.insertBefore(mediaEl, wrapper.firstChild);
}
cardObj.firstImgElement = mediaEl;
mediaEl.onclick = (ev) => {
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
openFileOrFolder(img);
return;
}
let activeSrc = mediaEl.currentSrc || mediaEl.src || src;
// If it's a preview blob, snapshot the rendered pixels from the card so revoked blobs don't 404 in fullscreen
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 = () => {
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);
}
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 the card still has other images, persist and switch
if (liveState.images && liveState.images.length > 0) {
cardObj.currentImageIndex = 0;
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
// If the card has text outputs or is in progress, persist
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
cardObj.lastImagesSignature = "";
store.updatePrompt(liveState.pid, liveState);
return;
}
store.deletePrompt(liveState.pid);
cardObj.element?.remove();
return;
}
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
};
const applyDimensions = (width, height) => {
if (cardObj.dimEl && width && height) {
// Don't show the dimension badge for active/running preview frames
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";
}
};
const isUnfinished = state.status ? (state.status !== "completed") : false;
if (isUnfinished) {
mediaEl.setAttribute("draggable", "false");
mediaEl.style.cursor = "grab";
if (mediaEl._currentDragStart) {
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
delete mediaEl._currentDragStart;
}
} else {
mediaEl.setAttribute("draggable", "true");
mediaEl.style.cursor = "zoom-in";
if (isVideo) {
const fullSrc = src.startsWith("http") ? src : window.location.origin + src;
const filename = img.filename || "output.mp4";
const mimeType = src.includes(".webm") ? "video/webm" : "video/mp4";
const videoDragHandler = (e) => {
try {
e.dataTransfer.setData("text/uri-list", fullSrc);
e.dataTransfer.setData("text/plain", fullSrc);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
e.stopPropagation();
};
if (mediaEl._currentDragStart) {
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
}
mediaEl.addEventListener("dragstart", videoDragHandler);
mediaEl._currentDragStart = videoDragHandler;
} else {
if (mediaEl._currentDragStart) {
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
delete mediaEl._currentDragStart;
}
}
}
if (isVideo) {
mediaEl.muted = true;
mediaEl.playsInline = true;
mediaEl.preload = "metadata";
mediaEl.loop = true;
mediaEl.onloadedmetadata = () => {
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
};
if (mediaEl.readyState >= 1) {
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
}
let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (!playIcon) {
playIcon = document.createElement("div");
playIcon.className = "comfy-sidebar-play-icon";
Object.assign(playIcon.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
});
playIcon.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
</svg>
`;
wrapper.appendChild(playIcon);
}
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
} else {
mediaEl.onload = () => {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
if (state._oldPreviewBlobUrl) {
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){}
delete state._oldPreviewBlobUrl;
}
};
if (mediaEl.complete && mediaEl.naturalWidth) {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
}
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
if (playIcon) playIcon.remove();
}
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
if (src.startsWith("blob:") && !isVideo) {
const tempImg = new Image();
tempImg.onload = () => {
const oldBlob = mediaEl._lastBlob;
mediaEl.src = src;
mediaEl._lastBlob = src;
applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight);
if (oldBlob && oldBlob !== src && oldBlob.startsWith("blob:")) {
try { URL.revokeObjectURL(oldBlob); } catch(e){}
}
};
tempImg.src = src;
} else {
mediaEl.src = isVideo ? src + "#t=0.001" : src;
}
} else {
if (isVideo) {
if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
} else {
if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
}
}
let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar");
if (state.images.length > 1) {
if (!navBar) {
navBar = document.createElement("div");
navBar.className = "comfy-sidebar-batch-navbar";
Object.assign(navBar.style, {
position: "absolute", bottom: "6px", left: "50%",
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)"
});
const prevBtn = document.createElement("span");
prevBtn.className = "pi pi-chevron-left";
prevBtn.style.cursor = "pointer";
prevBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
renderCardImages(cardObj, state, onNavigateBatch);
};
const label = document.createElement("span");
label.className = "comfy-sidebar-batch-label";
label.textContent = `${idx + 1}/${state.images.length}`;
label.style.cursor = "pointer";
label.title = "View all images in this batch";
label.onclick = (ev) => {
ev.stopPropagation();
if (typeof onNavigateBatch === "function") {
onNavigateBatch(state);
}
};
const nextBtn = document.createElement("span");
nextBtn.className = "pi pi-chevron-right";
nextBtn.style.cursor = "pointer";
nextBtn.onclick = (ev) => {
ev.stopPropagation();
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
renderCardImages(cardObj, state, onNavigateBatch);
};
navBar.append(prevBtn, label, nextBtn);
wrapper.appendChild(navBar);
}
const label = navBar.querySelector(".comfy-sidebar-batch-label");
if (label) label.textContent = `${idx + 1}/${state.images.length}`;
} else {
if (navBar) navBar.remove();
}
}
+1040
View File
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
export const isVideoFormat = (url) => {
const s = url.toLowerCase();
return s.includes(".mp4") || s.includes(".webm");
};
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
export function findImagesInOutputs(outputs) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (item && typeof item === 'object' && item.filename) list.push(item);
});
} else if (val && typeof val === 'object' && val.filename) {
list.push(val);
}
}
}
return list;
}
export function findTextsInOutputs(outputs) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (typeof item === 'string') {
list.push(item);
} else if (item && typeof item === 'object' && item.text) {
if (Array.isArray(item.text)) list.push(...item.text);
else if (typeof item.text === 'string') list.push(item.text);
}
});
} else if (typeof val === 'string') {
list.push(val);
} else if (val && typeof val === 'object' && val.text) {
if (Array.isArray(val.text)) list.push(...val.text);
else if (typeof val.text === 'string') list.push(val.text);
}
}
}
return list;
}
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
export function getRunOutputs(nodeOutputs) {
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] });
if (imgs.length > 0) {
list.push({ nodeId, images: imgs });
}
}
return list;
}
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
if (state.workflow && Array.isArray(state.workflow.nodes)) {
for (const node of state.workflow.nodes) {
if (node.title && node.title.toLowerCase().includes(q)) return true;
if (node.type && node.type.toLowerCase().includes(q)) return true;
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (w && w.value !== undefined && w.value !== null) {
if (String(w.value).toLowerCase().includes(q)) return true;
}
}
}
}
}
return false;
}
+996
View File
@@ -0,0 +1,996 @@
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js";
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;
return e.clientX || 0;
};
const getClientY = (e) => {
if (e.touches && e.touches.length > 0) return e.touches[0].clientY;
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY;
return e.clientY || 0;
};
function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
let togglePlayFn = () => {};
const overlay = new SidebarOverlay({
onDestroy,
onUserClose: () => {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
},
onKeyDown: (e) => {
if (e.key === " ") {
e.preventDefault();
togglePlayFn();
}
}
});
const playerBox = document.createElement("div");
Object.assign(playerBox.style, {
background: "linear-gradient(135deg, #1e1333 0%, #0f172a 100%)",
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",
pointerEvents: "auto"
});
const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav";
const parsedExt = (parsedFilename.split(".").pop() || "AUDIO").toUpperCase();
const titleRow = document.createElement("div");
Object.assign(titleRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between" });
const title = document.createElement("span");
title.textContent = parsedFilename;
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f1f5f9", fontFamily: "monospace", maxWidth: "75%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
const badge = document.createElement("span");
badge.className = "comfy-sidebar-audio-badge";
badge.style.position = "static";
badge.textContent = parsedExt;
titleRow.append(title, badge);
const soundwave = document.createElement("div");
soundwave.className = "comfy-sidebar-soundwave-container";
soundwave.style.height = "56px";
const barHeights = [14, 22, 38, 28, 48, 36, 20, 42, 54, 32, 18, 40, 50, 30, 22, 36, 48, 24, 18, 34, 46, 26, 16];
barHeights.forEach((h, i) => {
const bar = document.createElement("div");
bar.className = "comfy-sidebar-soundwave-bar";
bar.style.height = `${h}px`;
bar.style.width = "4px";
bar.style.animationDelay = `${(i * 0.06).toFixed(2)}s`;
soundwave.appendChild(bar);
});
const scrubberContainer = document.createElement("div");
Object.assign(scrubberContainer.style, {
width: "100%", height: "6px", background: "#334155", borderRadius: "3px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "3px" });
scrubberContainer.appendChild(scrubberFill);
const controlsRow = document.createElement("div");
Object.assign(controlsRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
const leftControls = document.createElement("div");
Object.assign(leftControls.style, { display: "flex", alignItems: "center", gap: "10px" });
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>`;
const playBtn = document.createElement("button");
Object.assign(playBtn.style, {
width: "36px", height: "36px", borderRadius: "50%", background: "#a855f7",
border: "none", color: "#fff", cursor: "pointer", display: "flex",
alignItems: "center", justifyContent: "center", fontSize: "14px"
});
playBtn.innerHTML = playIconSvg;
const timeLabel = document.createElement("span");
Object.assign(timeLabel.style, { fontSize: "11px", fontFamily: "monospace", color: "#94a3b8" });
timeLabel.textContent = "0:00 / 0:00";
leftControls.append(playBtn, timeLabel);
const rightControls = document.createElement("div");
Object.assign(rightControls.style, { display: "flex", alignItems: "center", gap: "8px" });
const loopBtn = document.createElement("button");
Object.assign(loopBtn.style, {
background: "transparent", border: "1px solid #444", color: "#aaa",
borderRadius: "4px", padding: "4px 8px", fontSize: "11px", cursor: "pointer"
});
loopBtn.innerHTML = `<i class="pi pi-replay"></i>`;
loopBtn.title = "Toggle Loop";
const speedSelect = document.createElement("select");
Object.assign(speedSelect.style, {
background: "#1e293b", border: "1px solid #444", color: "#eee",
borderRadius: "4px", padding: "2px 4px", fontSize: "11px", outline: "none"
});
["0.5x", "1.0x", "1.5x", "2.0x"].forEach(spd => {
const opt = document.createElement("option");
opt.value = spd.replace("x", "");
opt.textContent = spd;
if (spd === "1.0x") opt.selected = true;
speedSelect.appendChild(opt);
});
rightControls.append(loopBtn, speedSelect);
controlsRow.append(leftControls, rightControls);
playerBox.append(titleRow, soundwave, scrubberContainer, controlsRow);
overlay.container.appendChild(playerBox);
const audioEl = document.createElement("audio");
audioEl.src = baseSrc;
audioEl.preload = "metadata";
overlay.container.appendChild(audioEl);
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
audioEl.onloadedmetadata = () => {
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl.ontimeupdate = () => {
if (audioEl.duration > 0) {
const percent = (audioEl.currentTime / audioEl.duration) * 100;
scrubberFill.style.width = `${percent}%`;
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
}
};
audioEl.onended = () => {
playBtn.innerHTML = playIconSvg;
playerBox.classList.remove("playing");
scrubberFill.style.width = "0%";
};
togglePlayFn = () => {
if (audioEl.paused) {
stopAllAudioPlayback();
audioEl.play().catch(()=>{});
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
} else {
audioEl.pause();
audioEl.currentTime = 0;
playBtn.innerHTML = playIconSvg;
playerBox.classList.remove("playing");
scrubberFill.style.width = "0%";
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
}
};
playBtn.onclick = togglePlayFn;
loopBtn.onclick = () => {
audioEl.loop = !audioEl.loop;
loopBtn.style.borderColor = audioEl.loop ? "#a855f7" : "#444";
loopBtn.style.color = audioEl.loop ? "#c084fc" : "#aaa";
};
speedSelect.onchange = (e) => { audioEl.playbackRate = Number(e.target.value); };
scrubberContainer.onclick = (e) => {
const rect = scrubberContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
};
overlay.addCleanup(() => {
audioEl.pause();
audioEl.src = "";
});
audioEl.play().then(() => {
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
}).catch(()=>{});
return {
isAudio: true,
loadTarget(targetSrc) {
if (!isAudioFormat(targetSrc)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
const newFilename = getFilenameFromUrl(targetSrc) || "audio.wav";
title.textContent = newFilename;
badge.textContent = (newFilename.split(".").pop() || "AUDIO").toUpperCase();
audioEl.src = targetSrc;
audioEl.play().then(() => {
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
}).catch(()=>{});
},
destroy: () => overlay.destroy()
};
}
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
if (isVideo) {
el.muted = muted;
el.playsInline = true;
el.autoplay = true;
el.loop = true;
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1",
maxWidth: "calc(100vw - 120px)",
maxHeight: "calc(100vh - 120px)",
width: "auto",
height: "auto",
objectFit: "contain",
pointerEvents: isVideo ? "none" : "auto",
userSelect: "none",
webkitUserSelect: "none",
display: "block"
});
el.draggable = false;
el.ondragstart = (e) => e.preventDefault();
el.src = isVideo ? src + "#t=0.001" : src;
return el;
};
const setupVideoPlayback = (vid, container) => {
if (!vid) return () => {};
let videoSyncActive = true;
let syncAnimationFrameId = null;
const onMetaLoaded = () => { vid.play().catch(()=>{}); };
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
const controlBar = document.createElement("div");
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%",
display: "flex", alignItems: "center", gap: "12px",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.9))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace",
color: "var(--fg-color, #eee)",
width: "80%", maxWidth: "500px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)",
pointerEvents: "auto"
});
const playBtn = document.createElement("span");
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
playBtn.style.cursor = "pointer";
playBtn.style.fontSize = "14px";
playBtn.onclick = (e) => {
e.stopPropagation();
if (vid.paused) {
vid.play().catch(()=>{});
playBtn.className = "pi pi-pause";
} else {
vid.pause();
playBtn.className = "pi pi-play";
}
};
const scrubberContainer = document.createElement("div");
Object.assign(scrubberContainer.style, {
flex: "1", height: "4px", background: "#444", borderRadius: "2px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px" });
scrubberContainer.appendChild(scrubberFill);
const timeLabel = document.createElement("span");
timeLabel.textContent = "0:00 / 0:00";
const scrub = (e) => {
const rect = scrubberContainer.getBoundingClientRect();
const clientX = getClientX(e);
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
const duration = vid.duration || 0;
if (duration > 0) {
vid.currentTime = (percent / 100) * duration;
scrubberFill.style.width = `${percent}%`;
}
};
let isScrubbing = false;
scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
const handleWindowMove = (e) => { if (isScrubbing) scrub(e); };
const handleWindowUp = () => { isScrubbing = false; };
window.addEventListener("mousemove", handleWindowMove);
window.addEventListener("touchmove", handleWindowMove);
window.addEventListener("mouseup", handleWindowUp);
window.addEventListener("touchend", handleWindowUp);
controlBar.append(playBtn, scrubberContainer, timeLabel);
container.appendChild(controlBar);
const syncLoop = () => {
if (!videoSyncActive) return;
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
scrubberFill.style.width = `${(cur / dur) * 100}%`;
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
requestAnimationFrame(syncLoop);
return () => {
videoSyncActive = false;
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
window.removeEventListener("mousemove", handleWindowMove);
window.removeEventListener("touchmove", handleWindowMove);
window.removeEventListener("mouseup", handleWindowUp);
window.removeEventListener("touchend", handleWindowUp);
vid.removeEventListener("loadedmetadata", onMetaLoaded);
controlBar.remove();
};
};
function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const isBaseVideo = isVideoFormat(baseSrc);
let splitRatio = 50;
let mediaB = null;
const overlay = new SidebarOverlay({
onDestroy,
onUserClose: () => {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
},
onKeyDown: (e) => {
if (e.key === " " && mediaB) {
e.preventDefault();
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
}
});
// 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: "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",
pointerEvents: "auto"
});
overlay.container.appendChild(scrollContainer);
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30",
color: "var(--desc-color, #aaa)",
fontSize: "12px",
fontFamily: "sans-serif", pointerEvents: "none",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.85))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(6px)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center"
});
const infoText = document.createElement("span");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
header.appendChild(infoText);
overlay.container.appendChild(header);
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
margin: "auto", flexShrink: "0",
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%",
width: "2px", background: "#fff", cursor: "ew-resize",
zIndex: "25", display: "none", pointerEvents: "auto",
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
});
const sliderButton = document.createElement("div");
Object.assign(sliderButton.style, {
position: "absolute", top: "50%", left: "50%",
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
borderRadius: "50%", background: "#fff", color: "#333",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto"
});
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
const updateSliderPosition = (percent) => {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
if (mediaB) {
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
}
};
const cleanupMedia = (el) => {
if (el && el.tagName === "VIDEO") {
el.pause();
el.src = "";
el.load();
}
};
const syncImageScales = () => {
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) {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${wA}px`;
wrapper.style.height = `${hA}px`;
mediaA.style.maxWidth = "none";
mediaA.style.maxHeight = "none";
mediaA.style.width = "100%";
mediaA.style.height = "100%";
mediaA.style.objectFit = "fill";
} else {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
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 (!wB || !hB) return;
const arA = wA / hA;
const arB = wB / hB;
const arDiff = Math.abs(arA - arB) / Math.max(arA, arB);
if (arDiff > 0.05) {
infoText.textContent = `Aspect ratio mismatch (${wA}x${hA} vs ${wB}x${hB}). Images must have matching proportions to compare.`;
infoText.style.color = "#f87171";
setTimeout(() => {
if (infoText) {
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
}
}, 3500);
cleanupMedia(mediaB);
mediaB.remove();
mediaB = null;
slider.style.display = "none";
syncImageScales();
return;
}
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 = "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`;
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "none";
el.style.maxHeight = "none";
el.style.width = "100%";
el.style.height = "100%";
el.style.objectFit = "fill";
}
});
} else {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${fitWComp}px`;
wrapper.style.height = `${fitHComp}px`;
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "none";
el.style.maxHeight = "none";
el.style.width = "100%";
el.style.height = "100%";
el.style.objectFit = "fill";
}
});
}
};
mediaA.onload = () => {
hidePlaceholder();
syncImageScales();
};
if (mediaA.complete && mediaA.naturalWidth) {
hidePlaceholder();
syncImageScales();
}
// Dynamically adjust preview scale if sidebars are resized
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (!isZoomed) syncImageScales();
});
ro.observe(scrollContainer);
overlay.addCleanup(() => ro.disconnect());
}
const toggleZoom = () => {
if (isBaseVideo) return;
isZoomed = !isZoomed;
syncImageScales();
if (!isZoomed) {
scrollContainer.scrollTop = 0;
scrollContainer.scrollLeft = 0;
}
};
let isDraggingSlider = false, dragMoved = false, dragStartX = 0, dragStartY = 0;
const startDrag = (e) => {
if (e.button && e.button !== 0) return;
dragStartX = getClientX(e);
dragStartY = getClientY(e);
dragMoved = false;
isDraggingSlider = true;
};
const doDrag = (e) => {
if (!isDraggingSlider) return;
const clientX = getClientX(e);
const clientY = getClientY(e);
// 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();
if (rect.width > 0) {
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
}
}
};
const endDrag = () => {
if (!isDraggingSlider) return;
isDraggingSlider = false;
if (dragMoved) {
// Keep dragMoved true briefly so the upcoming click event doesn't trigger toggleZoom
setTimeout(() => { dragMoved = false; }, 60);
}
};
wrapper.addEventListener("mousedown", startDrag);
wrapper.addEventListener("touchstart", startDrag);
window.addEventListener("mousemove", doDrag);
window.addEventListener("touchmove", doDrag);
window.addEventListener("mouseup", endDrag);
window.addEventListener("touchend", endDrag);
overlay.addCleanup(() => {
window.removeEventListener("mousemove", doDrag);
window.removeEventListener("touchmove", doDrag);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
});
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.onUserClose?.();
overlay.destroy();
}
});
if (isBaseVideo) {
const destroyVideoPlayback = setupVideoPlayback(mediaA, overlay.container);
overlay.addCleanup(destroyVideoPlayback);
}
overlay.addCleanup(() => {
cleanupMedia(mediaA);
cleanupMedia(mediaB);
});
return {
is3D: false,
isAudio: false,
isText: false,
loadTarget(targetSrc, isShiftClick) {
const isTargetVideo = isVideoFormat(targetSrc);
const isTarget3D = is3DFormat(targetSrc);
const isTargetAudio = isAudioFormat(targetSrc);
const isTargetText = typeof targetSrc === "object" && targetSrc.text;
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
const currentPid = activeComparisonViewer?.targetPid;
overlay.destroy();
showFullscreenPreview([targetSrc], isShiftClick, currentPid);
return;
}
if (!targetSrc) {
showPlaceholder();
return;
}
if (isShiftClick) {
if (mediaB) {
cleanupMedia(mediaB);
mediaB.remove();
mediaB = null;
}
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
mediaB.onload = syncImageScales;
wrapper.appendChild(mediaB);
if (mediaB.complete && mediaB.naturalWidth) syncImageScales();
} else {
if (mediaB) {
cleanupMedia(mediaB);
mediaB.remove();
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();
};
}
},
destroy: () => overlay.destroy()
};
}
export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null) {
if (!imgSrcs || imgSrcs.length === 0) return;
const item = imgSrcs[0];
const targetPid = pid || (typeof item === "object" && item ? item.pid : null);
const pidStr = targetPid ? String(targetPid) : null;
// Automatically manage Runtime Preview mode based on the card clicked
if (pidStr) {
if (isLatestOrActiveTask(pidStr)) {
if (!isRuntimePreviewActive) setRuntimePreviewEnabled(true, false);
} else {
if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false);
}
}
// Handle Text Output
if (typeof item === "object" && item.text) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isText) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(item);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = createTextReader(
item,
(target) => showFullscreenPreview([target], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
const src = typeof item === "string" ? item : (item.url || item.filename || "");
// Delegate 3D formats
if (is3DFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.is3D) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = create3DViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
// Delegate Audio formats
if (isAudioFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isAudio) {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = createAudioViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc], false, pidStr),
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr;
return;
}
// Delegate 2D/Video formats
if (activeComparisonViewer) {
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
activeComparisonViewer.destroy();
activeComparisonViewer = null;
} else {
activeComparisonViewer.targetPid = pidStr;
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
}
activeComparisonViewer = createComparisonViewer(
src,
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) {
activeComparisonViewer.targetPid = pidStr;
}
}
+185
View File
@@ -0,0 +1,185 @@
import { app } from "/scripts/app.js";
import { store } from "../core/store.js";
async function promoteOrUploadToInput(imageObj) {
// 1. Try server-side promotion first (no browser RAM/re-upload overhead)
try {
const promoteRes = await fetch("/comfy-sidebar/promote-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(imageObj)
});
if (promoteRes.ok) {
const data = await promoteRes.json();
if (data.name) return data.name;
}
} catch (e) {}
// 2. Fallback to browser blob pipeline if backend promotion endpoint is unavailable
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
try {
const response = await fetch(src);
if (!response.ok) return null;
const blob = await response.blob();
const file = new File([blob], imageObj.filename || "dropped_file", { type: blob.type });
const formData = new FormData();
formData.append("image", file);
formData.append("overwrite", "true");
formData.append("type", "input");
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
if (!uploadRes.ok) return null;
const uploadData = await uploadRes.json();
return uploadData.name;
} catch(e) {
console.error("Comfy Sidebar: Failed to copy file to input.", e);
return null;
}
}
async function handleWorkflowDrop(e) {
const jsonStr = e.dataTransfer.getData("application/json");
if (!jsonStr) return false;
try {
const workflow = JSON.parse(jsonStr);
if (workflow && workflow.nodes) {
if (app.loadGraphData) {
app.loadGraphData(workflow);
} else if (app.handleFile) {
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
await app.handleFile(file);
}
return true;
}
} catch (err) {}
return false;
}
async function handleMediaDrop(e) {
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
if (!url) return false;
try {
const urlObj = new URL(url, window.location.origin);
if (urlObj.pathname !== "/view") return false;
const filename = urlObj.searchParams.get("filename");
const type = urlObj.searchParams.get("type") || "output";
const subfolder = urlObj.searchParams.get("subfolder") || "";
if (!filename) return false;
const canvas = app.canvas;
if (!canvas || !canvas.graph) return false;
let targetNode = null;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
} else if (canvas.canvas) {
const rect = canvas.canvas.getBoundingClientRect();
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
}
const widget = targetNode?.widgets?.find(w =>
w.name === "image" || w.name === "video" || w.name === "audio" || w.name === "audio_file" ||
w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" ||
w.type === "image" || w.type === "customtext"
);
const isLoadNode = targetNode && (
widget ||
targetNode.type?.includes("LoadImage") ||
targetNode.type?.includes("LoadVideo") ||
targetNode.type?.includes("VHS_LoadVideo") ||
targetNode.type?.includes("Load3D") ||
targetNode.type?.includes("Load 3D") ||
targetNode.type?.includes("LoadAudio") ||
targetNode.type?.includes("Load Audio") ||
targetNode.type?.includes("VHS_LoadAudio") ||
targetNode.type?.includes("Preview3D")
);
if (isLoadNode) {
const targetWidget = widget || targetNode.widgets?.[0];
if (targetWidget) {
const newFilename = await promoteOrUploadToInput({ filename, type, subfolder });
if (newFilename) {
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
if (!targetWidget.options.values.includes(newFilename)) {
targetWidget.options.values.push(newFilename);
}
}
targetWidget.value = newFilename;
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
targetNode.imgs = null;
app.graph.setDirtyCanvas(true, true);
return true;
}
}
} else {
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
const res = await fetch(src);
if (res.ok) {
const blob = await res.blob();
const file = new File([blob], filename, { type: blob.type });
if (app.handleFile) await app.handleFile(file);
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
return true;
}
}
} catch (err) {
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
}
return false;
}
const dropHandlers = [
handleWorkflowDrop,
handleMediaDrop
];
export function setupDragAndDrop() {
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onDrop = async (e) => {
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
(store.ui.sidebarContainer && store.ui.sidebarContainer.contains(e.target));
if (isSidebarDrop) {
e.preventDefault();
e.stopPropagation();
return;
}
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
const jsonStr = e.dataTransfer.getData("application/json");
const isOurMedia = url && url.includes("/view?");
const isOurWorkflow = jsonStr && jsonStr.includes('"nodes"');
// If it's an external file drop from outside the browser, let ComfyUI handle it natively
if (!isOurMedia && !isOurWorkflow) {
return;
}
// Synchronously stop ComfyUI core drop listeners from double-handling
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
for (const handler of dropHandlers) {
if (await handler(e)) {
return;
}
}
};
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop, true);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop, true);
};
}
+213
View File
@@ -0,0 +1,213 @@
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 node = targetNode || (app.canvas?.selected_nodes ? Object.values(app.canvas.selected_nodes)[0] : 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");
}
}
+185
View File
@@ -0,0 +1,185 @@
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();
// 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, {
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
background: options.background || "color-mix(in srgb, var(--comfy-menu-bg, #121212) 95%, transparent)",
backdropFilter: "blur(8px)",
color: "var(--fg-color, #eee)",
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", zIndex: "1000", boxSizing: "border-box",
overflow: "hidden", pointerEvents: "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";
this.closeBtn.title = "Close (Esc)";
Object.assign(this.closeBtn.style, {
position: "absolute", top: "16px", right: "24px", zIndex: "30",
cursor: "pointer", fontSize: "18px",
color: "var(--desc-color, #aaa)",
transition: "all 0.15s ease",
background: "var(--comfy-input-bg, rgba(20, 20, 20, 0.6))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.1))",
borderRadius: "50%", padding: "6px", pointerEvents: "auto"
});
this.closeBtn.onmouseenter = () => {
this.closeBtn.style.color = "var(--fg-color, #fff)";
this.closeBtn.style.borderColor = "var(--fg-color, #fff)";
};
this.closeBtn.onmouseleave = () => {
this.closeBtn.style.color = "var(--desc-color, #aaa)";
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
};
this.closeBtn.onclick = () => {
this.onUserClose();
this.destroy();
};
this.container.appendChild(this.closeBtn);
// 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);
this.updateOverlayBounds();
window.addEventListener("resize", this.updateOverlayBounds);
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
// 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);
};
document.addEventListener("keydown", onKeyDown);
this.cleanupFns.push(() => document.removeEventListener("keydown", onKeyDown));
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() {
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));
}
}
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) {
this.cleanupFns.push(fn);
}
destroy() {
document.body.classList.remove("comfy-sidebar-overlay-active");
for (const fn of this.cleanupFns.splice(0)) {
try { fn(); } catch (e) {}
}
this.container.remove();
this.onDestroy();
}
}
+573
View File
@@ -0,0 +1,573 @@
export function injectStyles() {
if (document.getElementById("comfy-sidebar-styles")) return;
const style = document.createElement("style");
style.id = "comfy-sidebar-styles";
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
border-radius: 4px; padding: 8px; position: relative;
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));
}
.comfy-sidebar-card:hover {
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
transition: border-color 0.2s, background-color 0.2s;
}
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
display: none !important;
}
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
display: flex !important;
}
/* 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,
.comfy-sidebar-card.active .comfy-sidebar-left-hover-panel {
display: none !important;
}
.comfy-sidebar-card-timer {
position: absolute; top: 6px; left: 8px; font-size: 10px;
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 10; color: #fff;
}
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important;
color: #e2e8f0 !important;
font-size: 11px !important;
border-radius: 6px !important;
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 {
background-color: #991b1b !important;
color: #ffffff !important;
}
.comfy-sidebar-hover-panel,
.comfy-sidebar-left-hover-panel {
display: none !important;
}
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-hover-panel,
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-left-hover-panel {
display: flex !important;
}
/* Card Action Buttons */
.comfy-sidebar-card-action-btn {
display: inline-flex;
align-items: center !important;
justify-content: center !important;
width: 30px !important;
height: 30px !important;
background-color: rgba(0, 0, 0, 0.75) !important;
color: #e2e8f0 !important;
font-size: 13px !important;
border-radius: 6px !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
z-index: 20 !important;
transform: translateZ(0);
}
.comfy-sidebar-card-action-btn:hover {
background-color: rgba(0, 0, 0, 0.95) !important;
color: #ffffff !important;
}
/* Delete Confirmation Active State */
.comfy-sidebar-card-action-btn.confirm-delete,
.comfy-sidebar-card-action-btn.confirm-delete:hover {
background-color: #dc3545 !important;
color: #ffffff !important;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
}
/* Disk Delete Confirmation */
.comfy-sidebar-card-action-btn.confirm-delete-disk,
.comfy-sidebar-card-action-btn.confirm-delete-disk:hover {
background-color: #7f1d1d !important;
color: #ffc9c9 !important;
box-shadow: 0 0 10px rgba(220, 38, 38, 0.85) !important;
border: 1px solid #ef4444 !important;
}
/* Dynamic Ctrl Key Hover State */
.comfy-sidebar-del-label {
display: none;
margin-left: 5px;
font-size: 10px;
font-weight: bold;
white-space: nowrap;
font-family: sans-serif;
letter-spacing: 0.3px;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card-action-btn:not(.comfy-sidebar-btn-del) {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-left-hover-panel {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del {
background-color: #991b1b !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
width: auto !important;
padding: 0 8px !important;
border-radius: 6px !important;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6) !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del .comfy-sidebar-del-label {
display: inline;
}
/* Translucent Folder Indicator on Ctrl Press */
.comfy-sidebar-ctrl-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #38bdf8;
z-index: 15;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
border-radius: 4px;
}
.comfy-sidebar-ctrl-overlay i {
font-size: 26px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
}
.comfy-sidebar-ctrl-overlay span {
font-size: 11px;
font-weight: bold;
color: #f1f5f9;
font-family: sans-serif;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-ctrl-overlay {
opacity: 1;
}
.comfy-sidebar-header-btn {
background: transparent;
color: var(--desc-color, #aaa);
border: 1px solid var(--border-color, #555);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease-in-out;
}
.comfy-sidebar-header-btn:hover {
border-color: var(--fg-color, #eee);
color: var(--fg-color, #eee);
}
.comfy-sidebar-icon-btn {
cursor: pointer;
font-size: 13px;
opacity: 0.6;
transition: opacity 0.15s ease-in-out;
}
.comfy-sidebar-icon-btn:hover {
opacity: 1;
}
.comfy-sidebar-scroll-top-btn {
position: absolute;
bottom: 14px;
right: 28px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(30, 30, 30, 0.9);
color: #eee;
border: 1px solid var(--border-color, #555);
cursor: pointer;
font-size: 13px;
display: none;
align-items: center;
justify-content: center;
z-index: 999;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
transition: all 0.15s ease-in-out;
opacity: 0.85;
}
.comfy-sidebar-scroll-top-btn:hover {
opacity: 1;
background: var(--p-primary-color, #3b82f6);
color: #fff;
border-color: var(--p-primary-color, #3b82f6);
}
/* 3D Preview Cards */
.comfy-sidebar-3d-wrapper {
width: 100%;
height: 160px;
background: linear-gradient(135deg, #1e1e2e 0%, #0f172a 100%);
border-radius: 4px;
display: flex;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease-in-out;
user-select: none;
cursor: pointer;
}
.comfy-sidebar-3d-wrapper:hover {
border-color: #3b82f6;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
}
.comfy-sidebar-3d-preview-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.45);
transition: filter 0.2s ease, transform 0.2s ease;
pointer-events: none;
z-index: 1;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-preview-img {
filter: brightness(0.6);
transform: scale(1.03);
}
.comfy-sidebar-3d-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
.comfy-sidebar-3d-icon {
font-size: 38px;
color: #60a5fa;
margin-bottom: 6px;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8));
transition: transform 0.2s ease;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-icon {
transform: scale(1.1) rotate(6deg);
}
.comfy-sidebar-3d-badge {
position: absolute;
top: 6px;
right: 6px;
background: rgba(59, 130, 246, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.comfy-sidebar-3d-title {
font-size: 10px;
color: #f1f5f9;
font-family: monospace;
max-width: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(0, 0, 0, 0.65);
padding: 2px 6px;
border-radius: 3px;
backdrop-filter: blur(2px);
z-index: 3;
}
/* Audio Preview Cards */
.comfy-sidebar-audio-wrapper {
width: 100%;
min-height: 110px;
background: linear-gradient(135deg, #1e1333 0%, #0f172a 100%);
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 12px 12px 12px;
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;
cursor: pointer;
}
.comfy-sidebar-audio-wrapper:hover {
border-color: #a855f7;
box-shadow: 0 0 12px rgba(168, 85, 247, 0.35);
}
.comfy-sidebar-audio-badge {
background: rgba(168, 85, 247, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.comfy-sidebar-audio-title {
font-size: 10px;
color: #f1f5f9;
font-family: monospace;
max-width: 65%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 3;
}
.comfy-sidebar-soundwave-container {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 36px;
margin: 4px 0;
}
.comfy-sidebar-soundwave-bar {
width: 3px;
background: #a855f7;
border-radius: 2px;
height: 8px;
transition: height 0.1s ease, background-color 0.2s ease;
}
.comfy-sidebar-audio-wrapper.playing .comfy-sidebar-soundwave-bar {
background: #c084fc;
animation: comfySidebarWave 1s ease-in-out infinite alternate;
}
@keyframes comfySidebarWave {
0% { transform: scaleY(0.4); }
100% { transform: scaleY(1.3); }
}
/* Generic File Preview Cards */
.comfy-sidebar-file-wrapper {
width: 100%;
min-height: 88px;
background: linear-gradient(135deg, #131d2e 0%, #0b1120 100%);
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px 10px;
box-sizing: border-box;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease-in-out;
user-select: none;
cursor: pointer;
gap: 8px;
}
.comfy-sidebar-file-wrapper:hover {
border-color: #0ea5e9;
box-shadow: 0 0 14px rgba(14, 165, 233, 0.35);
transform: translateY(-1px);
}
.comfy-sidebar-file-badge {
background: rgba(14, 165, 233, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 0 6px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
white-space: nowrap;
}
.comfy-sidebar-file-title {
font-size: 11px;
color: #f1f5f9;
font-family: monospace;
font-weight: 600;
word-break: break-all;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.comfy-sidebar-file-icon {
font-size: 14px;
color: #38bdf8;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-3px);
}
.comfy-sidebar-file-subtext {
font-size: 9px;
color: #94a3b8;
font-family: sans-serif;
display: flex;
align-items: center;
gap: 4px;
opacity: 0.9;
}
.comfy-sidebar-text-clamp {
font-size: 11px;
line-height: 1.45;
color: #cbd5e1;
max-height: 84px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
cursor: pointer;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 18px;
word-break: break-word;
transition: all 0.15s ease-in-out;
}
.comfy-sidebar-text-clamp:hover {
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);
}
+360
View File
@@ -0,0 +1,360 @@
import { SidebarOverlay } from "./overlay.js";
// Global helper for code block copying in Markdown view
window.moonCopyCode = function(button) {
try {
const container = button.parentElement;
if (!container) return;
const codeEl = container.querySelector("code");
if (!codeEl) return;
const text = codeEl.innerText;
navigator.clipboard.writeText(text).then(() => {
button.innerText = "Copied!";
button.style.backgroundColor = "#4CAF50";
button.style.borderColor = "#45a049";
button.style.color = "#ffffff";
setTimeout(() => {
button.innerText = "Copy";
button.style.backgroundColor = "#333333";
button.style.borderColor = "#555555";
button.style.color = "#aaaaaa";
}, 1500);
}).catch(() => {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
});
} catch (e) {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
}
};
function parseInlineMarkdown(html) {
if (!html) return "";
html = html.replace(/`([^`]+)`/g, '<code style="background:#252525;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e06c75;border:1px solid #333;font-size:12px;">$1</code>');
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong style="color:#ffffff;font-weight:600;"><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
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(/\$(?!\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;
}
function parseMarkdown(text) {
if (!text) return "";
let rawLines = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").split("\n");
let htmlLines = [];
let inCodeBlock = false, codeContent = [], codeLang = "";
let inUl = false, inOl = false, inTable = false;
let tableHeaders = [], tableAlignments = [], tableRows = [];
function closeUl() { if (inUl) { htmlLines.push("</ul>"); inUl = false; } }
function closeOl() { if (inOl) { htmlLines.push("</ol>"); inOl = false; } }
function closeTable() {
if (inTable) {
let tableHtml = `<table style="border-collapse:collapse;width:100%;margin:12px 0;font-size:12px;border:1px solid #333;color:#ddd;text-align:left;">`;
if (tableHeaders.length > 0) {
tableHtml += `<thead style="background:#1a1a1a;font-weight:600;color:#fff;border-bottom:2px solid #444;"><tr>`;
for (let i = 0; i < tableHeaders.length; i++) {
const align = tableAlignments[i] || 'left';
tableHtml += `<th style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(tableHeaders[i])}</th>`;
}
tableHtml += `</tr></thead>`;
}
tableHtml += "<tbody>";
for (let r = 0; r < tableRows.length; r++) {
const row = tableRows[r];
const bg = r % 2 === 0 ? 'rgba(255,255,255,0.015)' : 'transparent';
tableHtml += `<tr style="background:${bg};border-bottom:1px solid #222;">`;
for (let i = 0; i < tableHeaders.length; i++) {
const align = tableAlignments[i] || 'left';
tableHtml += `<td style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(row[i] || '')}</td>`;
}
tableHtml += `</tr>`;
}
tableHtml += "</tbody></table>";
htmlLines.push(tableHtml);
inTable = false;
tableHeaders = []; tableAlignments = []; tableRows = [];
}
}
for (let line of rawLines) {
const trimmed = line.trim();
if (trimmed.startsWith("```")) {
closeUl(); closeOl(); closeTable();
if (inCodeBlock) {
const rCode = codeContent.join("\n");
const langBadge = codeLang ? `<div style="position:absolute;top:6px;left:10px;color:#85c5ec;font-size:10px;font-family:sans-serif;font-weight:bold;text-transform:uppercase;opacity:0.8;user-select:none;">${codeLang}</div>` : '';
const btnStyle = "position:absolute;top:6px;right:6px;background:#333;color:#aaa;border:1px solid #555;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">Copy</button><pre style="background:#1e1e1e;padding:10px;padding-top:28px;border-radius:4px;border:1px solid #333;font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
codeContent = []; codeLang = ""; inCodeBlock = false;
} else {
inCodeBlock = true; codeLang = trimmed.slice(3).trim();
}
continue;
}
if (inCodeBlock) { codeContent.push(line); continue; }
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
closeUl(); closeOl(); closeTable();
htmlLines.push(`<hr style="border:0;border-top:1px solid #333;margin:16px 0;">`);
continue;
}
if (trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
closeUl(); closeOl(); closeTable();
htmlLines.push(`<div style="text-align:center;margin:12px 0;font-family:'Times New Roman',Times,serif;font-size:16px;color:#e5c07b;background:rgba(255,255,255,0.01);padding:10px;border-radius:4px;border:1px dashed #3e4451;font-style:italic;">${trimmed.slice(2, -2).trim()}</div>`);
continue;
}
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
closeUl(); closeOl();
const isSeparator = /^\|[\s\-\:\s\|]+\|$/.test(trimmed);
const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
if (isSeparator) {
tableAlignments = cells.map(c => (c.startsWith(":") && c.endsWith(":")) ? "center" : c.endsWith(":") ? "right" : "left");
inTable = true;
} else {
if (!inTable) { tableHeaders = cells; inTable = true; } else { tableRows.push(cells); }
}
continue;
} else { closeTable(); }
if (trimmed.startsWith(">")) {
closeUl(); closeOl();
const match = trimmed.match(/^([>\s]+)(.*)/);
if (match) {
const depth = (match[1].match(/>/g) || []).length;
let quote = parseInlineMarkdown(match[2].trim());
for (let d = 0; d < depth; d++) {
quote = `<blockquote style="border-left:4px solid #4CAF50;margin:6px 0;padding:6px 12px;color:#aaa;font-style:italic;background-color:rgba(255,255,255,0.015);">${quote}</blockquote>`;
}
htmlLines.push(quote);
}
continue;
}
if (line.startsWith("### ")) {
closeUl(); closeOl();
htmlLines.push(`<h3 style="margin:12px 0 6px 0;color:#ffffff;font-weight:600;font-size:14px;">${parseInlineMarkdown(line.slice(4))}</h3>`);
continue;
} else if (line.startsWith("## ")) {
closeUl(); closeOl();
htmlLines.push(`<h2 style="margin:16px 0 8px 0;color:#ffffff;font-weight:600;font-size:16px;border-bottom:1px solid #333;padding-bottom:4px;">${parseInlineMarkdown(line.slice(3))}</h2>`);
continue;
} else if (line.startsWith("# ")) {
closeUl(); closeOl();
htmlLines.push(`<h1 style="margin:20px 0 10px 0;color:#ffffff;font-weight:700;font-size:18px;border-bottom:2px solid #444;padding-bottom:6px;">${parseInlineMarkdown(line.slice(2))}</h1>`);
continue;
}
const ulMatch = line.match(/^(\s*)([-*])\s(.*)/);
if (ulMatch) {
closeUl();
if (!inUl) { htmlLines.push(`<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">`); inUl = true; }
const indent = ulMatch[1].length;
const content = ulMatch[3].trim();
const paddingLeft = indent > 0 ? `${indent * 8}px` : "0px";
if (content.startsWith("[ ]")) {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
} else if (content.toLowerCase().startsWith("[x]")) {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" checked disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
} else {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(content)}</li>`);
}
continue;
}
const olMatch = line.match(/^(\s*)(\d+)\.\s(.*)/);
if (olMatch) {
closeUl();
if (!inOl) { htmlLines.push(`<ol style="margin:8px 0;padding-left:24px;list-style-type:decimal;">`); inOl = true; }
const indent = olMatch[1].length;
htmlLines.push(`<li style="margin-left:${indent > 0 ? `${indent * 8}px` : "0px"};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(olMatch[3].trim())}</li>`);
continue;
}
if (trimmed === "") {
closeUl(); closeOl();
htmlLines.push("<br>");
} else {
closeUl(); closeOl();
htmlLines.push(`<p style="margin:6px 0;color:#ddd;line-height:1.6;">${parseInlineMarkdown(line)}</p>`);
}
}
closeUl(); closeOl(); closeTable();
return htmlLines.join("\n");
}
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}) {
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 readerBox = document.createElement("div");
Object.assign(readerBox.style, {
background: "var(--comfy-menu-bg, rgba(18, 18, 24, 0.95))",
backdropFilter: "blur(12px)",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
borderRadius: "12px",
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex",
flexDirection: "column", gap: "14px", zIndex: "20",
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",
flexWrap: "wrap", gap: "10px"
});
const titleInfo = document.createElement("div");
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 = 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", flexShrink: "0"
});
titleInfo.append(title, statsBadge);
const actionsRow = document.createElement("div");
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" });
const modeBtn = document.createElement("button");
Object.assign(modeBtn.style, {
background: "#334155", border: "1px solid #64748b", color: "#f8fafc",
borderRadius: "6px", padding: "5px 12px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", minWidth: "96px", height: "28px", boxSizing: "border-box"
});
modeBtn.innerHTML = `<i class="pi pi-code"></i><span>Markdown</span>`;
const copyBtn = document.createElement("button");
Object.assign(copyBtn.style, {
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
});
copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`;
const downloadBtn = document.createElement("button");
Object.assign(downloadBtn.style, {
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
});
downloadBtn.innerHTML = `<i class="pi pi-download"></i><span>.txt</span>`;
actionsRow.append(modeBtn, copyBtn, downloadBtn);
headerRow.append(titleInfo, actionsRow);
const textArea = document.createElement("div");
Object.assign(textArea.style, {
flex: "1", overflowY: "auto", fontSize: "13px",
lineHeight: "1.6",
color: "var(--fg-color, #e2e8f0)",
wordBreak: "break-word",
userSelect: "text", "-webkit-user-select": "text",
background: "var(--comfy-input-bg, rgba(0, 0, 0, 0.2))",
padding: "16px 20px", borderRadius: "6px",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.08))",
scrollbarWidth: "thin"
});
const updateView = () => {
const wc = rawText.trim() ? rawText.trim().split(/\s+/).length : 0;
statsBadge.textContent = `${wc} words • ${rawText.length} chars`;
if (isMarkdownMode) {
modeBtn.innerHTML = `<i class="pi pi-code"></i><span>Markdown</span>`;
modeBtn.style.background = "#3b82f6";
modeBtn.style.borderColor = "#60a5fa";
textArea.style.fontFamily = "sans-serif";
textArea.style.whiteSpace = "normal";
textArea.innerHTML = parseMarkdown(rawText);
} else {
modeBtn.innerHTML = `<i class="pi pi-align-left"></i><span>Raw Text</span>`;
modeBtn.style.background = "#334155";
modeBtn.style.borderColor = "#64748b";
textArea.style.fontFamily = "monospace";
textArea.style.whiteSpace = "pre-wrap";
textArea.textContent = rawText;
}
};
modeBtn.onclick = () => {
isMarkdownMode = !isMarkdownMode;
updateView();
};
copyBtn.onclick = () => {
navigator.clipboard.writeText(rawText);
copyBtn.innerHTML = `<i class="pi pi-check" style="color:#10b981;"></i><span style="color:#10b981;">Copied!</span>`;
setTimeout(() => { copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`; }, 1800);
};
downloadBtn.onclick = () => {
const blob = new Blob([rawText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `output_${pid || "text"}.txt`;
a.click();
URL.revokeObjectURL(url);
};
updateView();
readerBox.append(headerRow, textArea);
overlay.container.appendChild(readerBox);
return {
isText: true,
loadTarget(targetData) {
if (typeof targetData !== "object" || !targetData.text) {
overlay.destroy();
onSwitchMedia(targetData);
return;
}
rawText = targetData.text || "";
pid = targetData.pid || "";
title.textContent = formatTitle(pid);
title.title = pid ? `Text Output #${pid}` : "Text Output";
updateView();
},
destroy: () => overlay.destroy()
};
}
+415
View File
@@ -0,0 +1,415 @@
import { app } from "/scripts/app.js";
const FILE_EXT_REGEX = /\.(safetensors|ckpt|pt|pth|bin|latent|onnx|engine|gguf|lora|zip|tar|gz|7z|json|csv|parquet|txt|yaml|yml|xml|pdf|blend|fbx|obj|glb|gltf|stl|ply|splat|spz|ksplat|png|jpg|jpeg|webp|gif|bmp|tiff|svg|mp4|webm|wav|mp3|ogg|flac|m4a|aac|opus)$/i;
export function getFilenameFromUrl(url) {
if (!url) return "";
if (typeof url === "object") {
url = url.filename || url.url || "";
}
const str = String(url);
try {
const origin = window.location?.origin && window.location.origin !== "null" ? window.location.origin : "http://localhost";
const u = new URL(str, origin);
if (u.searchParams && u.searchParams.get("filename")) {
return u.searchParams.get("filename");
}
const pathname = u.pathname || "";
return pathname.split("/").pop() || "";
} catch (e) {
const m = str.match(/[?&]filename=([^&#]+)/);
if (m) {
try { return decodeURIComponent(m[1]); } catch (err) { return m[1]; }
}
return str.split("?")[0].split("#")[0].split("/").pop() || "";
}
}
const checkExt = (url, extensions) => {
if (!url) return false;
const name = getFilenameFromUrl(url).toLowerCase();
const s = String(url).toLowerCase().split("?")[0].split("#")[0];
return extensions.some(ext => name.endsWith(ext) || s.endsWith(ext));
};
export const isImageFormat = (url) => {
if (!url) return false;
if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true;
return checkExt(url, [
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
".tiff", ".tif", ".svg", ".avif", ".ico", ".apng"
]);
};
export const isVideoFormat = (url) => {
return checkExt(url, [".mp4", ".webm", ".mov", ".mkv"]);
};
export const is3DFormat = (url) => {
return checkExt(url, [
".glb", ".gltf", ".obj", ".ply", ".stl",
".splat", ".spz", ".ksplat", ".fbx"
]);
};
export const isAudioFormat = (url) => {
return checkExt(url, [
".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", ".opus"
]);
};
export function parseWorkflow(workflow) {
if (!workflow) return null;
if (typeof workflow === "object") return workflow;
if (typeof workflow === "string") {
try { return JSON.parse(workflow); } catch (e) { return null; }
}
return null;
}
export function isNodeIgnored(nodeId, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
if (node?.properties?.ignoreInQueue !== undefined) {
return !!node.properties.ignoreInQueue;
}
}
if (!workflow && app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(String(nodeId))) : null;
if (canvasNode?.properties?.ignoreInQueue !== undefined) {
return !!canvasNode.properties.ignoreInQueue;
}
}
return false;
}
export function getNodeTitle(nodeId, rawWorkflow, promptGraph = null) {
const idStr = String(nodeId);
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === idStr);
if (node) {
return node.title || node.type || null;
}
}
if (promptGraph && promptGraph[idStr]) {
const pNode = promptGraph[idStr];
if (pNode._meta?.title) return pNode._meta.title;
if (pNode.class_type) return pNode.class_type;
}
if (app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(idStr)) : null;
if (canvasNode) {
return canvasNode.title || canvasNode.type || null;
}
}
return null;
}
function cleanSubfolder(subfolder) {
if (!subfolder) return "";
let s = String(subfolder).replace(/\\/g, "/").trim();
s = s.replace(/^\/+|\/+$/g, ""); // Strip leading/trailing slashes
if (s.toLowerCase() === "output") return "";
if (s.toLowerCase().startsWith("output/")) {
s = s.substring(7);
}
return s;
}
function extractFileCandidate(val, subfolder = "", type = "output", nodeType = "") {
if (!val || typeof val !== "string") return null;
let trimmed = val.replace(/\\/g, "/").trim();
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
return null;
}
if (!isNaN(Number(trimmed))) {
return null;
}
// Extract any path ending with a known media or model extension from text messages
const fileMatch = trimmed.match(/([a-zA-Z0-9_\-\.\/]+\.(?:safetensors|ckpt|pt|bin|latent|png|jpg|jpeg|webp|mp4|webm|wav|mp3|glb|gltf))/i);
if (fileMatch) {
trimmed = fileMatch[1];
}
// Auto-append .safetensors if this is a LoRA node/subfolder without an extension
if (!trimmed.includes(".")) {
const typeLower = (nodeType || "").toLowerCase();
const subLower = (subfolder || "").toLowerCase();
const isLora = typeLower.includes("lora") || subLower.includes("lora") || trimmed.includes("lora");
const isLatent = typeLower.includes("latent");
if (isLora) {
trimmed += ".safetensors";
} else if (isLatent) {
trimmed += ".latent";
} else {
return null;
}
}
const cleanName = trimmed.split("/").pop()?.trim() || "";
if (!cleanName || cleanName === "." || cleanName === "..") return null;
const dotIndex = cleanName.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === cleanName.length - 1) {
return null;
}
const ext = cleanName.substring(dotIndex + 1).toLowerCase();
const base = cleanName.substring(0, dotIndex).trim();
if (!base || !ext) return null;
const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100);
if (!isValidExt) return null;
let detectedSub = subfolder;
if (trimmed.includes("/")) {
detectedSub = trimmed.substring(0, trimmed.lastIndexOf("/"));
}
detectedSub = cleanSubfolder(detectedSub);
return {
filename: cleanName,
subfolder: detectedSub,
type: type || "output"
};
}
export function findImagesInOutputs(outputs, rawWorkflow) {
const list = [];
const seen = new Set();
const workflow = parseWorkflow(rawWorkflow);
const addCandidate = (item) => {
if (!item || !item.filename) return;
const key = `${item.type || "output"}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
list.push(item);
}
};
const scan = (obj, sub = "", typ = "output", nodeType = "") => {
if (!obj) return;
if (typeof obj === "string") {
const cand = extractFileCandidate(obj, sub, typ, nodeType);
if (cand) addCandidate(cand);
return;
}
if (Array.isArray(obj)) {
obj.forEach(item => scan(item, sub, typ, nodeType));
return;
}
if (typeof obj === "object") {
const targetSub = obj.subfolder !== undefined ? obj.subfolder : sub;
const targetTyp = obj.type || typ || "output";
const fname = obj.filename || obj.name || obj.file || obj.path ||
obj.lora_name || obj.model_name || obj.save_name ||
obj.saved_file || obj.output_path || obj.file_name ||
obj.output || obj.saved;
if (fname && typeof fname === "string") {
const cand = extractFileCandidate(fname, targetSub, targetTyp, nodeType);
if (cand) {
addCandidate(cand);
return;
}
}
for (const key in obj) {
scan(obj[key], targetSub, targetTyp, nodeType || key);
}
}
};
if (outputs) {
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
const node = workflow?.nodes?.find(n => String(n.id) === String(nodeId));
const nodeType = node ? (node.type || "") : "";
scan(outputs[nodeId], "", "output", nodeType);
}
}
if (list.length > 0) return list;
// Fallback: Check workflow nodes for Save / Extract / Export nodes
if (workflow && Array.isArray(workflow.nodes)) {
const targetNodeIds = outputs ? Object.keys(outputs).map(String) : null;
for (const node of workflow.nodes) {
if (node && !node.properties?.ignoreInQueue) {
if (targetNodeIds && targetNodeIds.length > 0 && !targetNodeIds.includes(String(node.id))) {
continue;
}
const typeStr = (node.type || "").toLowerCase();
const isLoaderNode = typeStr.includes("load") || typeStr.includes("loader") || typeStr.includes("input");
const isSaveNode = !isLoaderNode && (
typeStr.includes("save") ||
typeStr.includes("extract") ||
typeStr.includes("export") ||
typeStr.includes("writer")
);
if (isSaveNode && Array.isArray(node.widgets_values)) {
for (const val of node.widgets_values) {
if (typeof val === "string" && val.trim().length > 0) {
let defaultSub = "";
if (typeStr.includes("lora")) defaultSub = "loras";
const cand = extractFileCandidate(val, defaultSub, "output", typeStr);
if (cand) {
cand.isFallback = true;
addCandidate(cand);
}
}
}
}
}
}
}
return list;
}
export function findTextsInOutputs(outputs, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (typeof item === 'string' && !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);
}
});
} else if (typeof val === 'string' && !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);
}
}
}
return list;
}
export function getRunOutputs(nodeOutputs, rawWorkflow, promptGraph = null) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
const nodeOut = nodeOutputs[nodeId];
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
// Even if the output dict is empty, check if this node was a Save/Extract node
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
const title = getNodeTitle(nodeId, workflow, promptGraph);
list.push({ nodeId, images: imgs, nodeTitle: title });
}
continue;
}
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
const title = getNodeTitle(nodeId, workflow, promptGraph);
list.push({ nodeId, images: imgs, nodeTitle: title });
}
}
return list;
}
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 || [];
}
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
const workflow = parseWorkflow(state.workflow);
if (workflow && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.title && node.title.toLowerCase().includes(q)) return true;
if (node.type && node.type.toLowerCase().includes(q)) return true;
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (w && w.value !== undefined && w.value !== null) {
if (String(w.value).toLowerCase().includes(q)) return true;
}
}
}
}
}
return false;
}
export async function extractWorkflowFromPng(imageUrl) {
try {
const res = await fetch(imageUrl);
const buffer = await res.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) {
return null;
}
let offset = 8;
const utf8Decoder = new TextDecoder("utf-8");
while (offset < buffer.byteLength) {
const length = view.getUint32(offset);
const type = utf8Decoder.decode(new Uint8Array(buffer, offset + 4, 4));
if (type === "tEXt") {
const chunkData = new Uint8Array(buffer, offset + 8, length);
const nullIdx = chunkData.indexOf(0);
if (nullIdx !== -1) {
const keyword = utf8Decoder.decode(chunkData.subarray(0, nullIdx));
if (keyword === "workflow") {
const text = utf8Decoder.decode(chunkData.subarray(nullIdx + 1));
return JSON.parse(text);
}
}
} else if (type === "IEND") {
break;
}
offset += 12 + length;
}
} catch (e) {
console.warn("Comfy Sidebar: Could not extract workflow from PNG", e);
}
return null;
}
+560
View File
@@ -0,0 +1,560 @@
import { is3DFormat, getFilenameFromUrl } from "./utils.js";
import { SidebarOverlay } from "./overlay.js";
let cachedThreeLibs = null;
async function getThreeLibs() {
if (cachedThreeLibs) return cachedThreeLibs;
// Check if Three.js was already loaded on window by another ComfyUI 3D node
if (window.THREE) {
const OrbitControls = window.THREE.OrbitControls || window.OrbitControls;
const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader;
if (OrbitControls && GLTFLoader) {
cachedThreeLibs = {
...window.THREE,
OrbitControls,
GLTFLoader,
OBJLoader: window.THREE.OBJLoader || window.OBJLoader,
STLLoader: window.THREE.STLLoader || window.STLLoader,
PLYLoader: window.THREE.PLYLoader || window.PLYLoader
};
return cachedThreeLibs;
}
}
const loadFromBase = async (base) => {
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
import(`${base}three@0.170.0`),
import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null)
]);
return { three, controls, gltf, obj, stl, ply };
};
try {
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,
GLTFLoader: gltf.GLTFLoader || gltf.default || gltf,
OBJLoader: obj?.OBJLoader || obj?.default || obj,
STLLoader: stl?.STLLoader || stl?.default || stl,
PLYLoader: ply?.PLYLoader || ply?.default || ply
};
return cachedThreeLibs;
} catch (err) {
console.error("Comfy Sidebar: Failed to load Three.js libraries:", err);
return null;
}
}
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
const overlay = new SidebarOverlay({
className: "comfy-sidebar-3d-overlay-root",
onDestroy
});
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30", color: "#aaa", fontSize: "12px",
fontFamily: "monospace", pointerEvents: "none", background: "rgba(10,10,10,0.75)",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(4px)",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
});
const titleText = document.createElement("span");
titleText.textContent = getFilenameFromUrl(baseSrc) || "3D Model";
header.appendChild(titleText);
overlay.container.appendChild(header);
const canvasContainer = document.createElement("div");
Object.assign(canvasContainer.style, {
width: "100%", height: "100%", position: "relative",
display: "flex", alignItems: "center", justifyContent: "center"
});
overlay.container.appendChild(canvasContainer);
const loadingSpinner = document.createElement("div");
Object.assign(loadingSpinner.style, {
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
color: "#60a5fa", fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
background: "rgba(0,0,0,0.8)", padding: "10px 18px", borderRadius: "8px",
display: "flex", alignItems: "center", gap: "10px"
});
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
overlay.container.appendChild(loadingSpinner);
const settingsPanel = document.createElement("div");
Object.assign(settingsPanel.style, {
position: "absolute", top: "60px", right: "20px", width: "230px",
background: "rgba(18, 18, 24, 0.9)", backdropFilter: "blur(8px)",
border: "1px solid rgba(255, 255, 255, 0.12)", borderRadius: "8px",
padding: "14px", zIndex: "35", color: "#e2e8f0", fontSize: "11px",
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", display: "none", flexDirection: "column", gap: "12px"
});
const createFormGroup = (label, inputEl) => {
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", flexDirection: "column", gap: "4px" });
const lbl = document.createElement("label");
lbl.textContent = label;
Object.assign(lbl.style, { color: "#94a3b8", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
row.append(lbl, inputEl);
return row;
};
const createSelect = (options, value, onChange) => {
const sel = document.createElement("select");
Object.assign(sel.style, {
background: "#252530", color: "#eee", border: "1px solid #444",
borderRadius: "4px", padding: "4px 6px", fontSize: "11px", outline: "none"
});
options.forEach(opt => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === value) o.selected = true;
sel.appendChild(o);
});
sel.onchange = (e) => onChange(e.target.value);
return sel;
};
const toolbar = document.createElement("div");
Object.assign(toolbar.style, {
position: "absolute", bottom: "20px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "8px", zIndex: "30", background: "rgba(20,20,25,0.85)",
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(6px)",
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.1)",
alignItems: "center"
});
const createToolbarBtn = (iconClass, label, activeDefault, onClick) => {
const btn = document.createElement("button");
let active = activeDefault;
btn.className = "comfy-sidebar-3d-tool-btn";
Object.assign(btn.style, {
background: active ? "#3b82f6" : "transparent", color: active ? "#fff" : "#aaa",
border: "1px solid " + (active ? "#3b82f6" : "#444"), borderRadius: "4px",
padding: "4px 10px", fontSize: "11px", cursor: "pointer", display: "flex",
alignItems: "center", gap: "6px", transition: "all 0.15s ease"
});
btn.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
btn.onclick = (e) => {
e.stopPropagation();
active = !active;
btn.style.background = active ? "#3b82f6" : "transparent";
btn.style.color = active ? "#fff" : "#aaa";
btn.style.borderColor = active ? "#3b82f6" : "#444";
onClick(active);
};
return btn;
};
let renderer, scene, camera, cameraPersp, cameraOrtho, controls, currentModel, gridHelper, animId;
let ambientLight, dirLight1, dirLight2;
let autoRotate = false, showWireframe = false, currentMaterialMode = "original", currentUpDirection = "y-up";
let lightMultiplier = 1.0;
let THREE = null;
const applyMaterialMode = () => {
if (!currentModel || !THREE) return;
currentModel.traverse((child) => {
if (child.isMesh) {
if (currentMaterialMode === "clay") {
child.material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.6, metalness: 0.1 });
} else if (currentMaterialMode === "normal") {
child.material = new THREE.MeshNormalMaterial();
} else {
child.material = child._originalMaterial || child.material;
}
if (Array.isArray(child.material)) {
child.material.forEach(m => m.wireframe = showWireframe);
} else if (child.material) {
child.material.wireframe = showWireframe;
}
}
});
};
const applyUpDirection = () => {
if (!currentModel || !THREE) return;
if (currentUpDirection === "z-up") {
currentModel.rotation.x = -Math.PI / 2;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
} else if (currentUpDirection === "z-down") {
currentModel.rotation.x = Math.PI / 2;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
} else {
currentModel.rotation.x = 0;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
}
if (controls && camera) {
fitCameraToObject(camera, currentModel, controls, THREE);
if (gridHelper) gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
}
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportModel = async (format) => {
if (!currentModel) return;
const baseName = (titleText.textContent || "model").replace(/\.[^/.]+$/, "");
try {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
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;
const exporter = new OBJExporter();
const result = exporter.parse(currentModel);
const blob = new Blob([result], { type: "text/plain" });
downloadBlob(blob, `${baseName}.obj`);
} else if (format === "GLB") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`);
const GLTFExporter = mod.GLTFExporter || mod.default || mod;
const exporter = new GLTFExporter();
exporter.parse(currentModel, (gltf) => {
const blob = new Blob([gltf], { type: "application/octet-stream" });
downloadBlob(blob, `${baseName}.glb`);
}, (err) => { console.error(err); }, { binary: true });
} else if (format === "STL") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`);
const STLExporter = mod.STLExporter || mod.default || mod;
const exporter = new STLExporter();
const result = exporter.parse(currentModel, { binary: true });
const blob = new Blob([result], { type: "application/octet-stream" });
downloadBlob(blob, `${baseName}.stl`);
}
} catch (err) {
console.error("Comfy Sidebar: Export failed", err);
} finally {
loadingSpinner.style.display = "none";
}
};
const cleanup3D = () => {
if (animId) cancelAnimationFrame(animId);
if (controls) controls.dispose();
if (currentModel && scene) scene.remove(currentModel);
if (renderer) {
renderer.dispose();
renderer.domElement.remove();
}
};
overlay.addCleanup(cleanup3D);
const initSceneAndLoad = async (srcUrl) => {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model";
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.innerHTML = `
<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;
}
if (!renderer) {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0e0e12);
const aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
cameraPersp = new THREE.PerspectiveCamera(45, aspect, 0.01, 1000);
cameraPersp.position.set(0, 1.5, 3.5);
const d = 2;
cameraOrtho = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.01, 1000);
cameraOrtho.position.set(0, 1.5, 3.5);
camera = cameraPersp;
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
ambientLight = new THREE.AmbientLight(0xffffff, 1.2 * lightMultiplier);
scene.add(ambientLight);
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.8 * lightMultiplier);
dirLight1.position.set(5, 10, 7);
scene.add(dirLight1);
dirLight2 = new THREE.DirectionalLight(0x90b0ff, 0.8 * lightMultiplier);
dirLight2.position.set(-5, -5, -5);
scene.add(dirLight2);
gridHelper = new THREE.GridHelper(10, 20, 0x3b82f6, 0x22222a);
gridHelper.position.y = 0;
scene.add(gridHelper);
const bgColorInput = document.createElement("input");
bgColorInput.type = "color";
bgColorInput.value = "#0e0e12";
Object.assign(bgColorInput.style, { width: "100%", height: "26px", borderRadius: "4px", border: "none", cursor: "pointer" });
bgColorInput.oninput = (e) => { scene.background.set(e.target.value); };
const upDirSelect = createSelect([
{ value: "y-up", label: "Original (Y-Up)" },
{ value: "z-up", label: "Z-Up (+90°)" },
{ value: "z-down", label: "Inverted (-90°)" }
], currentUpDirection, (val) => {
currentUpDirection = val;
applyUpDirection();
});
const matModeSelect = createSelect([
{ value: "original", label: "Original / Textured" },
{ value: "clay", label: "Clay / Studio Matte" },
{ value: "normal", label: "Normals" }
], currentMaterialMode, (val) => {
currentMaterialMode = val;
applyMaterialMode();
});
const camTypeSelect = createSelect([
{ value: "perspective", label: "Perspective" },
{ value: "orthographic", label: "Orthographic" }
], "perspective", (val) => {
const prevPos = camera.position.clone();
const prevTarget = controls.target.clone();
if (val === "orthographic") camera = cameraOrtho;
else camera = cameraPersp;
camera.position.copy(prevPos);
controls.object = camera;
controls.target.copy(prevTarget);
controls.update();
});
const fovContainer = document.createElement("div");
Object.assign(fovContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
const fovSlider = document.createElement("input");
Object.assign(fovSlider, { type: "range", min: "15", max: "120", value: "45" });
fovSlider.style.flex = "1";
const fovVal = document.createElement("span");
fovVal.textContent = "45°";
fovSlider.oninput = (e) => {
const v = Number(e.target.value);
fovVal.textContent = `${v}°`;
cameraPersp.fov = v;
cameraPersp.updateProjectionMatrix();
};
fovContainer.append(fovSlider, fovVal);
const lightContainer = document.createElement("div");
Object.assign(lightContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
const lightSlider = document.createElement("input");
Object.assign(lightSlider, { type: "range", min: "0.1", max: "3.0", step: "0.1", value: "1.0" });
lightSlider.style.flex = "1";
const lightVal = document.createElement("span");
lightVal.textContent = "1.0x";
lightSlider.oninput = (e) => {
lightMultiplier = Number(e.target.value);
lightVal.textContent = `${lightMultiplier.toFixed(1)}x`;
ambientLight.intensity = 1.2 * lightMultiplier;
dirLight1.intensity = 1.8 * lightMultiplier;
dirLight2.intensity = 0.8 * lightMultiplier;
};
lightContainer.append(lightSlider, lightVal);
settingsPanel.append(
createFormGroup("Background Color", bgColorInput),
createFormGroup("Up Direction", upDirSelect),
createFormGroup("Material Mode", matModeSelect),
createFormGroup("Camera Type", camTypeSelect),
createFormGroup("FOV", fovContainer),
createFormGroup("Light Intensity", lightContainer)
);
overlay.container.appendChild(settingsPanel);
const btnGrid = createToolbarBtn("pi pi-table", "Grid", true, (v) => { gridHelper.visible = v; });
const btnRotate = createToolbarBtn("pi pi-sync", "Auto-Rotate", false, (v) => { autoRotate = v; });
const btnWire = createToolbarBtn("pi pi-box", "Wireframe", false, (v) => {
showWireframe = v;
applyMaterialMode();
});
const btnResetCam = document.createElement("button");
Object.assign(btnResetCam.style, {
background: "transparent", color: "#aaa", border: "1px solid #444",
borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer",
display: "flex", alignItems: "center", gap: "6px"
});
btnResetCam.innerHTML = `<i class="pi pi-compass"></i><span>Reset View</span>`;
btnResetCam.onclick = (e) => {
e.stopPropagation();
if (currentModel) fitCameraToObject(camera, currentModel, controls, THREE);
};
const btnSettings = createToolbarBtn("pi pi-sliders-h", "Settings", false, (v) => {
settingsPanel.style.display = v ? "flex" : "none";
});
const exportSelect = createSelect([
{ value: "", label: "Export As..." },
{ value: "OBJ", label: "OBJ (.obj)" },
{ value: "GLB", label: "GLB / GLTF" },
{ value: "STL", label: "STL (.stl)" }
], "", (val) => {
if (val) {
exportModel(val);
exportSelect.value = "";
}
});
exportSelect.style.background = "#1e293b";
exportSelect.style.borderColor = "#3b82f6";
exportSelect.style.color = "#93c5fd";
toolbar.append(btnGrid, btnWire, btnRotate, btnResetCam, btnSettings, exportSelect);
overlay.container.appendChild(toolbar);
const onResize = () => {
if (!canvasContainer || !renderer || !cameraPersp || !cameraOrtho) return;
const width = canvasContainer.clientWidth;
const height = canvasContainer.clientHeight;
const aspect = width / height;
cameraPersp.aspect = aspect;
cameraPersp.updateProjectionMatrix();
const d = 2;
cameraOrtho.left = -d * aspect;
cameraOrtho.right = d * aspect;
cameraOrtho.top = d;
cameraOrtho.bottom = -d;
cameraOrtho.updateProjectionMatrix();
renderer.setSize(width, height);
};
window.addEventListener("resize", onResize);
overlay.addCleanup(() => window.removeEventListener("resize", onResize));
const animate = () => {
animId = requestAnimationFrame(animate);
if (autoRotate && currentModel) currentModel.rotation.y += 0.008;
controls.update();
renderer.render(scene, camera);
};
animate();
}
if (currentModel) {
scene.remove(currentModel);
currentModel = null;
}
try {
const filename = getFilenameFromUrl(srcUrl);
const ext = (filename.split(".").pop() || "glb").toLowerCase();
let loadedObj = null;
if (ext === "obj" && THREE.OBJLoader) {
const loader = new THREE.OBJLoader();
loadedObj = await loader.loadAsync(srcUrl);
} else if (ext === "stl" && THREE.STLLoader) {
const loader = new THREE.STLLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4, metalness: 0.2 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (ext === "ply" && THREE.PLYLoader) {
const loader = new THREE.PLYLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (THREE.GLTFLoader) {
const loader = new THREE.GLTFLoader();
const gltfData = await loader.loadAsync(srcUrl);
loadedObj = gltfData.scene || gltfData.scenes?.[0];
}
if (loadedObj) {
currentModel = loadedObj;
currentModel.traverse((child) => {
if (child.isMesh && child.material) child._originalMaterial = child.material;
});
applyMaterialMode();
applyUpDirection();
scene.add(currentModel);
fitCameraToObject(camera, currentModel, controls, THREE);
gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
}
} catch (err) {
console.error("Comfy Sidebar: Failed to parse 3D model", err);
loadingSpinner.innerHTML = `<span style="color:#f87171;">Failed to load 3D format</span>`;
return;
}
loadingSpinner.style.display = "none";
};
function fitCameraToObject(cam, obj, ctrl, THREE) {
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
if (cam.isPerspectiveCamera) {
const fov = cam.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
cam.position.set(center.x, center.y + size.y * 0.2, center.z + cameraZ);
} else if (cam.isOrthographicCamera) {
cam.position.set(center.x, center.y + size.y * 0.2, center.z + maxDim * 2);
}
cam.lookAt(center);
ctrl.target.copy(center);
ctrl.update();
}
initSceneAndLoad(baseSrc);
return {
is3D: true,
loadTarget(targetSrc) {
if (!is3DFormat(targetSrc)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
initSceneAndLoad(targetSrc);
},
destroy: () => overlay.destroy()
};
}
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "comfy-sidebar"
description = "A compact and powerful layout overhaul for ComfyUI that makes generation results easier to browse, preview, compare, and manage."
version = "1.7.0"
license = { file = "LICENSE" }
dependencies = [
"send2trash>=1.8.0",
]
[project.urls]
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
[tool.comfy]
PublisherId = "soundslikethunder"
DisplayName = "Comfy Sidebar"
Icon = "https://raw.githubusercontent.com/m0rtus59/ComfyUI-ComfySidebar/main/icon.png"
+1
View File
@@ -0,0 +1 @@
send2trash>=1.8.0