Compare commits

...
37 Commits
Author SHA1 Message Date
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
36 changed files with 7484 additions and 2540 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 }}
+2 -1
View File
@@ -1,2 +1,3 @@
__pycache__/
*.pyc
*.pyc
js/vendor/
+40 -11
View File
@@ -1,19 +1,24 @@
# 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.
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
## ✨ 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.
* Browse generation results in a compact grid.
* Drag results back onto the canvas to restore workflows.
* Drag images directly into `LoadImage` and compatible input nodes.
* Preview images, videos, and text outputs.
* Compare two results side by side with `Shift+Click`.
* Download generated files or workflow JSON directly from result cards.
* Show queue progress and the node currently being executed.
* Hide sidebar tabs and UI elements you don't use.
* Optionally replace the stock Job History sidebar.
* Optional unified top bar for workflow tabs and controls.
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
@@ -32,8 +37,32 @@ Restart your ComfyUI server and refresh the browser tab.
## ⚙️ Configuration
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:
* Queue grid layout
* Result aspect ratio
* Queue display mode
* Working node display
* Automatic cleanup of cancelled/failed jobs
* Sidebar tabs to hide
* Stock Job History replacement
* Graph button visibility
* Unified top bar layout
## ⌨️ Shortcuts
* **`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.
| Shortcut | Action |
| ------------- | -------------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open file location/alternative action |
## 📝 Notes
ComfySidebar integrates with the ComfyUI frontend and therefore depends on parts of its UI structure. Frontend changes in ComfyUI may require corresponding updates to this extension.
## License
See [LICENSE](LICENSE).
+231 -11
View File
@@ -1,20 +1,240 @@
# 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)
return real_target == real_base or real_target.startswith(real_base + os.sep)
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"):
"""Finds the actual location of a file or folder across all candidate root directories."""
candidates = 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:
os.startfile(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)
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
return web.json_response({"error": "File not found or unsafe path"}, status=404)
# Try moving to Recycle Bin / Trash first, fallback to os.remove
try:
import send2trash
send2trash.send2trash(target_file)
except Exception:
os.remove(target_file)
return web.json_response({"success": True, "deleted": target_file})
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)
# ============================================================================
# Local Three.js Proxy & Offline Cache (Bypasses CSP & --disable-api-nodes)
# ============================================================================
SIDEBAR_CACHE_DIR = os.path.join(os.path.dirname(__file__), "js", "vendor", "cache")
@PromptServer.instance.routes.get("/comfy-sidebar/three-proxy/{path:.*}")
async def three_proxy_handler(request):
raw_path = request.match_info.get("path", "").lstrip("/")
if not raw_path:
return web.Response(status=404)
query = request.query_string
full_req = f"{raw_path}?{query}" if query else raw_path
os.makedirs(SIDEBAR_CACHE_DIR, exist_ok=True)
import re
safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', full_req)
if not any(safe_name.endswith(ext) for ext in [".js", ".mjs", ".wasm", ".bin", ".json"]):
safe_name += ".js"
cache_file = os.path.join(SIDEBAR_CACHE_DIR, safe_name)
# 1. Serve immediately from disk cache if present
if os.path.isfile(cache_file) and os.path.getsize(cache_file) > 0:
content_type = "application/wasm" if raw_path.endswith(".wasm") else "application/javascript"
return web.FileResponse(cache_file, headers={"Content-Type": content_type})
# 2. Otherwise download via Python (not blocked by browser CSP)
url = f"https://esm.sh/{full_req}"
try:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
with urllib.request.urlopen(req, timeout=20) as resp:
raw_data = resp.read()
if raw_path.endswith((".wasm", ".bin", ".png", ".jpg")):
with open(cache_file, "wb") as f:
f.write(raw_data)
return web.Response(body=raw_data, content_type="application/octet-stream")
content = raw_data.decode("utf-8", errors="replace")
# Rewrite absolute esm.sh paths to route through our local 'self' proxy
content = content.replace("https://esm.sh/", "/comfy-sidebar/three-proxy/")
# Rewrite root-relative module paths (e.g. from "/v135/...")
content = re.sub(
r'((?:from|import)\s*["\'])/(v\d+|[a-zA-Z0-9@_.~-]+/)',
r'\1/comfy-sidebar/three-proxy/\2',
content
)
with open(cache_file, "w", encoding="utf-8") as f:
f.write(content)
return web.Response(text=content, content_type="application/javascript")
except Exception as e:
return web.Response(text=f"/* Proxy error fetching {url}: {e} */", status=502, content_type="application/javascript")
+202
View File
@@ -0,0 +1,202 @@
import { app } from "/scripts/app.js";
export function findOriginalPropertiesButton() {
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return byTestId;
}
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
for (const icon of icons) {
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return btn;
}
}
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
return btn;
}
}
return null;
}
export function findTopbarContainer() {
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalPropertiesButton();
if (orig?.parentNode) return orig.parentNode;
const buttons = document.querySelectorAll("button, .p-button");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("run") || title.includes("manager")) {
if (btn.parentNode) return btn.parentNode;
}
}
return null;
}
export function isPropertiesPanelOpen() {
const panel = document.querySelector('[data-testid="properties-panel"]');
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
const headings = document.querySelectorAll('h1, h2, h3, h4');
for (const h of headings) {
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
}
const origBtn = findOriginalPropertiesButton();
if (origBtn) {
if (origBtn.classList.contains("p-button-active") ||
origBtn.classList.contains("active") ||
origBtn.getAttribute("aria-expanded") === "true" ||
origBtn.getAttribute("aria-pressed") === "true") {
return true;
}
}
return false;
}
export function findActiveQueueIndicator() {
const container = findTopbarContainer();
if (!container) return null;
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const text = (btn.textContent || "").toLowerCase();
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
return btn;
}
}
return null;
}
export function findNativeExtensionsPanel() {
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
}
export function findGraphButton() {
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
for (const el of elements) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
return el.closest('.group, .p-1') || el;
}
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
for (const el of fallbacks) {
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
}
}
return null;
}
export function findOurSidebarButton() {
const icon = document.querySelector('.pi-images');
return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null;
}
export function findStandardQueueButton() {
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) {
const icon = document.querySelector(iconSelector);
if (icon) {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn && !btn.querySelector('.pi-images')) return btn;
}
}
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
for (const btn of buttons) {
const title = btn.title || btn.getAttribute('aria-label') || '';
if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) &&
!btn.querySelector('.pi-images') &&
!btn.id?.includes('classic-comfy-sidebar')) {
return btn;
}
}
return null;
}
export function updateSidebarBadge(count) {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn) {
let badge = btn.querySelector('.comfy-sidebar-badge');
if (count > 0) {
if (!badge) {
btn.style.position = 'relative';
badge = document.createElement('div');
badge.className = 'comfy-sidebar-badge';
Object.assign(badge.style, {
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
});
btn.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
} else if (badge) {
badge.remove();
}
}
});
}
export function applySidebarOverride() {
const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
if (stdBtn) {
if (!stdBtn._originalDisplay) {
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
}
if (overrideStock) {
stdBtn.style.setProperty("display", "none", "important");
if (!stdBtn._overrideClickListener) {
stdBtn._overrideClickListener = (e) => {
e.preventDefault();
e.stopPropagation();
const b = findOurSidebarButton();
if (b) b.click();
};
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
}
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) {
stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
}
} else {
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
if (stdBtn._overrideClickListener) {
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
stdBtn._overrideClickListener = null;
}
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
stdBtn.parentNode.appendChild(ourBtn);
}
}
}
}
export function centerAndSelectCanvasNode(nodeId) {
if (!app.graph || !app.canvas) return false;
const id = typeof nodeId === "string" ? Number(nodeId) : nodeId;
const node = app.graph.getNodeById ? (app.graph.getNodeById(id) || app.graph.getNodeById(String(nodeId))) : null;
if (node) {
if (app.canvas.centerOnNode) app.canvas.centerOnNode(node);
if (app.canvas.selectNode) app.canvas.selectNode(node);
return true;
}
return false;
}
+457
View File
@@ -0,0 +1,457 @@
import { app } from "/scripts/app.js";
import {
findOriginalPropertiesButton,
findTopbarContainer,
isPropertiesPanelOpen,
findActiveQueueIndicator,
findNativeExtensionsPanel,
findGraphButton
} from "./adapter.js";
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 tabIconSelectors = {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"Templates": '[class*="comfy--template"]'
};
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
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");
}
}
});
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 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;
}
export function syncClassicLayout() {
if (domObserver) domObserver.disconnect();
try {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
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");
}
}
const extensionsPanel = findNativeExtensionsPanel();
if (extensionsPanel) {
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");
}
}
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();
const indicator = findActiveQueueIndicator();
if (indicator && indicator.style.display === "none") indicator.style.removeProperty("display");
updateSidebarTabsVisibility();
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";
}
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();
} 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 [];
}
};
+75
View File
@@ -0,0 +1,75 @@
import { PromptStatus } from "./constants.js";
export function createPromptState(pid, initial = {}) {
return {
pid: String(pid),
status: initial.status || PromptStatus.PENDING,
images: Array.isArray(initial.images) ? [...initial.images] : [],
texts: Array.isArray(initial.texts) ? [...initial.texts] : [],
nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {},
nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {},
progress: typeof initial.progress === "number" ? initial.progress : 0,
progressText: initial.progressText || "",
queueNumber: initial.queueNumber || null,
timestamp: typeof initial.timestamp === "number" ? initial.timestamp : Date.now(),
workflow: initial.workflow || null,
activeNodeName: initial.activeNodeName || "",
startTime: initial.startTime || null,
endTime: initial.endTime || null,
duration: typeof initial.duration === "number" ? initial.duration : null,
rendered: !!initial.rendered,
_previewBlobUrl: initial._previewBlobUrl || null,
_oldPreviewBlobUrl: initial._oldPreviewBlobUrl || null
};
}
export function revokePromptBlobs(state) {
if (!state) return;
if (state._previewBlobUrl) {
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
state._previewBlobUrl = null;
}
if (state._oldPreviewBlobUrl) {
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
state._oldPreviewBlobUrl = null;
}
}
export function activatePrompt(state, patch = {}) {
state.status = PromptStatus.ACTIVE;
state.progress = 0;
state.progressText = "Processing...";
state.rendered = false;
state.startTime = state.startTime || Date.now();
state.duration = null;
if (patch.workflow) state.workflow = patch.workflow;
if (patch.activeNodeName !== undefined) state.activeNodeName = patch.activeNodeName;
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
if (typeof patch.timestamp === "number") state.timestamp = patch.timestamp;
return state;
}
export function updatePromptProgress(state, progress, activeNodeName = null) {
state.progress = Math.max(0, Math.min(100, Math.round(progress)));
if (activeNodeName !== null) {
state.activeNodeName = activeNodeName;
}
return state;
}
export function finalizePrompt(state, targetStatus, patch = {}) {
revokePromptBlobs(state);
state.status = targetStatus;
state.progressText = "";
state.rendered = false;
state.endTime = patch.endTime || Date.now();
if (state.startTime) {
state.duration = (state.endTime - state.startTime) / 1000;
}
if (patch.workflow) state.workflow = patch.workflow;
if (patch.nodeOutputs) state.nodeOutputs = patch.nodeOutputs;
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
if (patch.images && patch.images.length > 0) state.images = patch.images;
if (patch.texts && patch.texts.length > 0) state.texts = patch.texts;
return state;
}
+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();
}
+177
View File
@@ -0,0 +1,177 @@
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 } 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);
}
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);
// 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 || '')}`);
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);
}
}
+224 -125
View File
@@ -1,125 +1,224 @@
// 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 { 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
});
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
sidebarTabs.forEach(tab => {
app.ui.settings.addSetting({
id: `Comfy Sidebar.Hide Junk.${tab}`,
name: `Hide Tab: ${tab}`,
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
});
app.ui.settings.addSetting({
id: SettingIds.OVERRIDE_STOCK_HISTORY,
name: "Replace the stock Job History sidebar with Comfy Queue",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized && app.ui?.settings) {
syncStockHistoryAndProgressSettings(value);
}
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.HIDE_GRAPH_BUTTON,
name: "Hide floating 'Graph' (Workflow/Node Map) button",
type: "boolean",
defaultValue: false,
onChange: () => {
setTimeout(() => { syncClassicLayout(); }, 0);
}
});
app.ui.settings.addSetting({
id: SettingIds.CLASSIC_LAYOUT,
name: "Places the controls and the open workflow tabs on a single unified top bar",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (isInitialized) {
applyClassicLayout(value, true);
}
}
});
}
function setupKeydownShortcuts() {
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
if (activeEl && (
activeEl.tagName === "INPUT" ||
activeEl.tagName === "TEXTAREA" ||
activeEl.isContentEditable ||
activeEl.tagName === "SELECT"
)) return;
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleIgnoreActiveNode(() => renderSidebar());
}
};
document.addEventListener("keydown", activeKeydownHandler, true);
}
app.registerExtension({
name: "ComfySidebar.ClassicRestore",
init() {
registerSettings();
},
commands: [
{
id: "ComfySidebar.ToggleSidebar",
label: "Toggle Comfy Queue Sidebar",
function: () => {
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
},
{
id: "ComfySidebar.ToggleIgnoreNode",
label: "Toggle Ignore Selected Node(s) in Queue",
function: () => toggleIgnoreActiveNode(() => renderSidebar())
}
],
nodeCreated(node) {
if (node && node.properties?.ignoreInQueue) {
node.boxcolor = "#ff3333";
syncAllNodeBadges();
}
},
afterConfigureGraph() {
requestAnimationFrame(() => {
syncAllNodeBadges();
renderSidebar();
});
},
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
const isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
applyClassicLayout(isClassicLayoutEnabled, false);
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false;
if (isHistoryOverrideEnabled) {
syncStockHistoryAndProgressSettings(true);
}
isInitialized = true;
setupPropertiesPanelToggleFix();
injectStyles();
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupVueNodeObserver());
// Setup execution tracker with targeted fast-path update for active card
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
const cardObj = cardPool.get(pid);
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
+34
View File
@@ -0,0 +1,34 @@
let currentlyPlayingAudio = null;
let activeComparisonViewer = null;
export function stopAllAudioPlayback() {
if (currentlyPlayingAudio) {
currentlyPlayingAudio.pause();
currentlyPlayingAudio.currentTime = 0;
if (typeof currentlyPlayingAudio._onResetUI === "function") {
currentlyPlayingAudio._onResetUI();
}
currentlyPlayingAudio = null;
}
}
export function setCurrentlyPlayingAudio(audio) {
stopAllAudioPlayback();
currentlyPlayingAudio = audio;
}
export function getCurrentlyPlayingAudio() {
return currentlyPlayingAudio;
}
export function setActiveComparisonViewer(viewer) {
activeComparisonViewer = viewer;
}
export function getActiveComparisonViewer() {
return activeComparisonViewer;
}
export function isAudioViewerOpen() {
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
}
+535
View File
@@ -0,0 +1,535 @@
import { app } from "/scripts/app.js";
import { PromptStatus } from "../core/constants.js";
import { store } from "../core/store.js";
import { renderCardImages } from "./mediaPreview.js";
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage } from "./mediaActions.js";
import { centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { getRunOutputs, extractWorkflowFromPng, isImageFormat } from "../utils/utils.js";
import { showFullscreenPreview } from "../utils/comparison.js";
import { stopAllAudioPlayback } from "./audioController.js";
import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js";
export const cardPool = new Map();
export function resetAllCardHoverStates() {
for (const cardObj of cardPool.values()) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
}
}
export function syncCardButtonVisibility(cardObj, state) {
if (!cardObj) return;
const isPendingOrActive = state.status === PromptStatus.PENDING || state.status === PromptStatus.ACTIVE;
if (isPendingOrActive) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
return;
} else {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display");
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
}
// 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 || '')}`;
a.download = img.filename || "output";
a.click();
});
};
} else {
cardObj.btnImg.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnCopy) {
const isCopyableImage = hasRealImages && currentImg && isImageFormat(currentImg.filename || currentImg.url);
if (isCopyableImage) {
cardObj.btnCopy.style.removeProperty("display");
cardObj.btnCopy.style.display = "inline-flex";
cardObj.btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
const success = await copyImageToClipboard(src);
if (success) {
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
cardObj.btnCopy.style.color = "#4ade80";
setTimeout(() => {
cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
cardObj.btnCopy.style.color = "";
}, 1500);
}
};
} else {
cardObj.btnCopy.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnJson) {
if (state.workflow || isPng) {
cardObj.btnJson.style.removeProperty("display");
cardObj.btnJson.style.display = "inline-flex";
cardObj.btnJson.onclick = async (ev) => {
ev.stopPropagation();
let wf = state.workflow;
if (!wf && currentImg) {
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
wf = await extractWorkflowFromPng(src);
if (wf) state.workflow = wf;
}
if (!wf) {
alert("No workflow metadata found in this file.");
return;
}
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflow_${state.pid || "comfy"}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
};
} else {
cardObj.btnJson.style.setProperty("display", "none", "important");
}
}
if (cardObj.btnDel) {
// Always show the delete button so cards can be cleared from history
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) {
// Focus the image's node, or the node where the workflow stopped/errored
const nodeId = (currentImg ? findNodeIdForImage(state, currentImg) : null) || state.activeNodeId;
if (nodeId) {
cardObj.btnFocus.style.removeProperty("display");
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.filter(o => o.images && o.images.length > 0);
const primarySignatures = new Set((state.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
const distinctOutputs = [];
for (const out of validOutputs) {
const hasNewImage = out.images.some(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
if (hasNewImage) {
distinctOutputs.push(out);
}
}
// Show intermediate button if run completed with multiple outputs, or if stopped with saved outputs
const hasIntermediates = (validOutputs.length > 1 && distinctOutputs.length > 0) ||
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
if (hasIntermediates) {
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
cardObj.leftHoverBtn.onclick = (ev) => {
ev.stopPropagation();
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
store.openOutputsSubmenu(state.pid);
};
} else {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
}
}
}
export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true, 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}%`;
}
// Fast-path: update live preview image directly on active card
if (state && state.images && state.images.length > 0) {
const firstImg = state.images[0];
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}` : 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: "10"
});
const sBadge = document.createElement("div");
Object.assign(sBadge.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold",
padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none",
pointerEvents: "none", zIndex: "10"
});
const dimEl = document.createElement("div");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const pt = document.createElement("div");
Object.assign(pt.style, {
width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px",
overflow: "hidden", display: "none"
});
const pb = document.createElement("div");
Object.assign(pb.style, {
width: "0%", height: "100%", background: "#3b82f6", transition: "width 0.1s linear"
});
pt.appendChild(pb);
const statusText = document.createElement("div");
Object.assign(statusText.style, {
fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px",
display: "none", fontWeight: "bold"
});
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
btnCopy.style.display = "none";
const btnJson = document.createElement("span");
btnJson.className = "pi pi-file comfy-sidebar-card-action-btn";
btnJson.title = "Download JSON";
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
const leftHoverBtn = document.createElement("span");
leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn";
leftHoverBtn.title = "View all intermediate outputs";
hoverPanel.append(btnImg, btnJson, btnDel);
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
card.append(timerEl, cancelX, sBadge, dimEl, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
timerEl,
statusBadge: sBadge,
dimEl,
grid,
placeholder: p,
progressContainer: pt,
progressBar: pb,
cancelBtn: cancelX,
hoverPanel,
leftHoverPanel,
btnFocus,
leftHoverBtn,
btnImg,
btnCopy,
btnJson,
btnDel,
btnDelLabel,
canDeleteFromDisk: true,
statusText,
firstImgElement: null,
lastImagesSignature: "",
currentImageIndex: 0
};
card.addEventListener("mouseenter", () => {
// Only run automatic button visibility sync on main queue cards (avoid synthetic submenu IDs)
if (store.hasPrompt(state.pid)) {
const liveState = store.getPrompt(state.pid);
syncCardButtonVisibility(cardObj, liveState);
}
});
card.addEventListener("dragstart", (e) => {
const liveState = store.getPrompt(state.pid) || state;
const isUnfinished = liveState.status && liveState.status !== PromptStatus.COMPLETED;
const hasNoImages = !liveState.images || liveState.images.length === 0;
if (liveState.workflow && (hasNoImages || isUnfinished)) {
if (cardObj.firstImgElement) {
try { e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15); } catch(err){}
}
const jsonStr = JSON.stringify(liveState.workflow, null, 2);
const filename = `workflow_${liveState.pid}.json`;
const base64Data = btoa(unescape(encodeURIComponent(jsonStr)));
try {
e.dataTransfer.setData("DownloadURL", `application/json:${filename}:data:application/json;base64,${base64Data}`);
e.dataTransfer.setData("application/json", jsonStr);
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
}
});
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
const resetDeleteBtn = () => {
isDeletePending = false;
isDiskDelete = false;
cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
cardObj.btnDel.title = "Delete Card (Ctrl+Click to delete from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing card";
deleteTimeout = setTimeout(resetDeleteBtn, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDeleteBtn();
const liveState = store.getPrompt(state.pid) || state;
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
}
}
};
(liveState.images || []).forEach(addFile);
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
for (const fileItem of filesToDelete) {
await deleteFileOnServer(fileItem);
}
}
deleteHistoryItem(state.pid);
}
};
cardPool.set(state.pid, cardObj);
return cardObj;
}
export function updateCardDOM(cardObj, state, showPendingSummary = true, showWorkingNode = true) {
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
cardObj.element.style.position = "relative";
const isUnfinished = state.status && state.status !== PromptStatus.COMPLETED;
if (state.images && state.images.length > 0 && !isUnfinished) {
cardObj.element.removeAttribute("draggable");
} else {
cardObj.element.setAttribute("draggable", "true");
}
if (state.status === PromptStatus.ACTIVE) {
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
cardObj.timerEl.style.display = "block";
} else if (state.duration !== undefined && state.duration !== null) {
cardObj.timerEl.textContent = state.duration.toFixed(2) + "s";
cardObj.timerEl.style.display = "block";
} else {
cardObj.timerEl.style.display = "none";
}
if (state.status === PromptStatus.CANCELLED) {
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" });
cardObj.statusBadge.textContent = "Cancelled";
} else if (state.status === PromptStatus.ERROR) {
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" });
cardObj.statusBadge.textContent = "Error";
} else {
cardObj.statusBadge.style.display = "none";
}
if (state.status === PromptStatus.PENDING && !showPendingSummary) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
cancelPendingTask(state.pid);
};
} else if (state.status === PromptStatus.ACTIVE) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
interruptActive();
};
} else {
cardObj.cancelBtn.style.display = "none";
}
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
if (cardObj.lastImagesSignature !== currentImagesSignature) {
if (!state.images || state.images.length === 0) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, state, (st) => {
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack?.parentElement
|| store.ui.cardStack;
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
store.openBatchSubmenu({
pid: st.pid,
images: st.images,
workflow: st.workflow,
nodeOutputs: st.nodeOutputs
});
});
}
cardObj.lastImagesSignature = currentImagesSignature;
}
if (state.images.length === 0) {
if (state.texts && state.texts.length > 0) {
const fullText = state.texts.join("\n\n");
cardObj.placeholder.textContent = fullText;
cardObj.placeholder.title = "Click to read full text";
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
cardObj.placeholder.style.cssText = "";
cardObj.placeholder.style.display = "-webkit-box";
cardObj.placeholder.onclick = (e) => {
e.stopPropagation();
stopAllAudioPlayback();
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
};
} else {
cardObj.placeholder.textContent = state.progressText || "No Outputs";
cardObj.placeholder.className = "";
Object.assign(cardObj.placeholder.style, {
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default"
});
cardObj.placeholder.onclick = null;
}
}
if (state.status === PromptStatus.ACTIVE) {
if (showWorkingNode) {
cardObj.statusText.style.display = "block";
const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]`) : "Processing...";
cardObj.statusText.textContent = `${nodeText}${state.progress ? ` ${state.progress}%` : ""}`;
} else {
cardObj.statusText.style.display = "none";
}
cardObj.progressContainer.style.display = "block";
cardObj.progressBar.style.width = `${state.progress || 0}%`;
} else {
cardObj.statusText.style.display = "none";
cardObj.progressContainer.style.display = "none";
}
syncCardButtonVisibility(cardObj, state);
return cardObj.element;
}
+119
View File
@@ -0,0 +1,119 @@
export async function copyImageToClipboard(src) {
try {
const res = await fetch(src);
let blob = await res.blob();
if (blob.type !== "image/png") {
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = src;
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
canvas.getContext("2d").drawImage(img, 0, 0);
blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
}
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return true;
} catch (err) {
console.error("Comfy Sidebar: Failed to copy image to clipboard", err);
return false;
}
}
export async function openFileOrFolder(img) {
if (!img) return;
try {
const res = await fetch("/comfy-sidebar/open-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: img.filename,
subfolder: img.subfolder || "",
type: img.type || "output"
})
});
if (res.ok) return;
} catch (e) {}
const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const a = document.createElement("a");
a.href = src;
a.target = "_blank";
a.download = img.filename || "file";
document.body.appendChild(a);
a.click();
a.remove();
}
export async function deleteFileOnServer(fileItem) {
if (!fileItem || !fileItem.filename) return false;
try {
const res = await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: fileItem.filename,
subfolder: fileItem.subfolder || "",
type: fileItem.type || "output"
})
});
return res.ok;
} catch (e) {
console.error("Comfy Sidebar: Failed to delete file on server", e);
return false;
}
}
export function removeImageFromNodeOutputs(nodeOutputs, targetImg) {
if (!nodeOutputs || !targetImg || !targetImg.filename) return;
const scanAndRemove = (obj) => {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const val = obj[key];
if (Array.isArray(val)) {
for (let i = val.length - 1; i >= 0; i--) {
const item = val[i];
if (item && typeof item === "object") {
if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) {
val.splice(i, 1);
} else {
scanAndRemove(item);
}
} else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) {
val.splice(i, 1);
}
}
} else if (typeof val === "object") {
scanAndRemove(val);
}
}
};
for (const nodeId in nodeOutputs) {
scanAndRemove(nodeOutputs[nodeId]);
}
}
export function findNodeIdForImage(state, img) {
if (!state || !state.nodeOutputs || !img) return null;
for (const nodeId in state.nodeOutputs) {
const out = state.nodeOutputs[nodeId];
for (const key in out) {
const val = out[key];
if (Array.isArray(val)) {
if (val.some(i => i && typeof i === "object" && i.filename === img.filename)) {
return nodeId;
}
} else if (val && typeof val === "object" && val.filename === img.filename) {
return nodeId;
}
}
}
return null;
}
+639
View File
@@ -0,0 +1,639 @@
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js";
import { openFileOrFolder } from "./mediaActions.js";
import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js";
import { showFullscreenPreview } from "../utils/comparison.js";
import { store } from "../core/store.js";
export function render3DCardPreview(cardObj, wrapper, src, img, state) {
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || [];
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
const bgImgSrc = lastImageAsset
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
: null;
if (!preview3D) {
wrapper.innerHTML = "";
preview3D = document.createElement("div");
preview3D.className = "comfy-sidebar-3d-wrapper";
if (bgImgSrc) {
const bgImg = document.createElement("img");
bgImg.className = "comfy-sidebar-3d-preview-img";
bgImg.src = bgImgSrc;
bgImg.alt = "3D Thumbnail Preview";
preview3D.appendChild(bgImg);
}
const overlay = document.createElement("div");
overlay.className = "comfy-sidebar-3d-overlay";
const badge = document.createElement("span");
badge.className = "comfy-sidebar-3d-badge";
const ext = (img.filename || src).split('.').pop().toUpperCase();
badge.textContent = ext || "3D";
const icon = document.createElement("span");
icon.className = "pi pi-box comfy-sidebar-3d-icon";
const title = document.createElement("span");
title.className = "comfy-sidebar-3d-title";
title.textContent = img.filename || "3D Model";
overlay.append(badge, icon, title);
preview3D.appendChild(overlay);
wrapper.appendChild(preview3D);
} else {
const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img");
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
bgImg.src = bgImgSrc;
} else if (!bgImg && bgImgSrc) {
const newBgImg = document.createElement("img");
newBgImg.className = "comfy-sidebar-3d-preview-img";
newBgImg.src = bgImgSrc;
newBgImg.alt = "3D Thumbnail Preview";
preview3D.insertBefore(newBgImg, preview3D.firstChild);
}
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
if (title) title.textContent = img.filename || "3D Model";
}
cardObj.firstImgElement = preview3D;
preview3D.onclick = (ev) => {
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl], ev.shiftKey);
};
preview3D.setAttribute("draggable", "true");
preview3D.ondragstart = (e) => {
e.stopPropagation();
const filename = img.filename || "model.glb";
const mimeType = "application/octet-stream";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
const ext = filename.split('.').pop().toUpperCase();
const playIconSvg = `<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" });
scrubber.appendChild(scrubberFill);
const timeLabel = document.createElement("div");
Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" });
timeLabel.textContent = "0:00 / 0:00";
bottomRow.append(scrubber, timeLabel);
const audioEl = document.createElement("audio");
audioEl.src = fullUrl;
audioEl.preload = "metadata";
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
const resetAudioUI = () => {
playBtn.innerHTML = playIconSvg;
previewAudio.classList.remove("playing");
scrubberFill.style.width = "0%";
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl._onResetUI = resetAudioUI;
audioEl.onloadedmetadata = () => {
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl.ontimeupdate = () => {
if (audioEl.duration > 0) {
const percent = (audioEl.currentTime / audioEl.duration) * 100;
scrubberFill.style.width = `${percent}%`;
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
}
};
audioEl.onended = () => {
resetAudioUI();
if (getCurrentlyPlayingAudio() === audioEl) stopAllAudioPlayback();
};
const togglePlay = (e) => {
e.stopPropagation();
if (isAudioViewerOpen()) {
stopAllAudioPlayback();
showFullscreenPreview([fullUrl]);
return;
}
if (audioEl.paused) {
setCurrentlyPlayingAudio(audioEl);
audioEl.play().catch(()=>{});
playBtn.innerHTML = stopIconSvg;
previewAudio.classList.add("playing");
} else {
audioEl.pause();
audioEl.currentTime = 0;
resetAudioUI();
stopAllAudioPlayback();
}
};
playBtn.onclick = togglePlay;
playBtn.onmouseenter = () => {
playBtn.style.background = "#9333ea";
playBtn.style.borderColor = "#c084fc";
playBtn.style.transform = "scale(1.08)";
};
playBtn.onmouseleave = () => {
playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)";
playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)";
playBtn.style.transform = "scale(1.0)";
};
scrubber.onclick = (e) => {
e.stopPropagation();
const rect = scrubber.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
};
previewAudio.append(topRow, centerArea, bottomRow, audioEl);
wrapper.appendChild(previewAudio);
previewAudio.onclick = (e) => {
if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return;
stopAllAudioPlayback();
if (e.ctrlKey || e.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl]);
};
}
cardObj.firstImgElement = previewAudio;
previewAudio.setAttribute("draggable", "true");
previewAudio.ondragstart = (e) => {
e.stopPropagation();
const mimeType = "audio/wav";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
if (!previewFile) {
wrapper.innerHTML = "";
previewFile = document.createElement("div");
previewFile.className = "comfy-sidebar-file-wrapper";
const topRow = document.createElement("div");
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" });
const badge = document.createElement("span");
badge.className = "comfy-sidebar-file-badge";
badge.textContent = ext;
topRow.appendChild(badge);
const centerArea = document.createElement("div");
Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" });
const icon = document.createElement("span");
icon.className = "pi pi-file comfy-sidebar-file-icon";
const title = document.createElement("span");
title.className = "comfy-sidebar-file-title";
title.textContent = filename;
title.title = filename;
centerArea.append(icon, title);
const bottomRow = document.createElement("div");
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
const folderInfo = document.createElement("div");
folderInfo.className = "comfy-sidebar-file-subtext";
folderInfo.innerHTML = `<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 src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const isVideo = isVideoFormat(src) || isVideoFormat(img.filename);
const is3D = is3DFormat(src) || is3DFormat(img.filename);
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes("."));
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
if (!wrapper) {
cardObj.grid.innerHTML = "";
wrapper = document.createElement("div");
wrapper.className = "comfy-sidebar-media-wrapper";
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
cardObj.grid.appendChild(wrapper);
}
let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay");
if (!ctrlOverlay) {
ctrlOverlay = document.createElement("div");
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
ctrlOverlay.innerHTML = `<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 = async () => {
if (src && !src.startsWith("blob:") && !is3D && !isAudio) {
try {
const res = await fetch(src, { method: "HEAD" });
if (res.status === 404) {
store.deletePrompt(state.pid);
if (cardObj.element) cardObj.element.remove();
return;
}
} catch (e) {}
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
}
};
const applyDimensions = (width, height) => {
if (cardObj.dimEl && width && height) {
// Don't show the dimension badge for active/running preview frames
if (state.status === "active") {
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();
}
}
+912
View File
@@ -0,0 +1,912 @@
import { app } from "/scripts/app.js";
import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js";
import { store } from "../core/store.js";
import { getOrCreateCard, updateCardDOM, resetAllCardHoverStates, cardPool } from "./cardRenderer.js";
import { clearCancelledOrFailed, clearAllHistory, cancelAllPending } from "../queue/queueService.js";
import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.js";
import { matchesFilter, getRunOutputs, isImageFormat } from "../utils/utils.js";
import { renderCardImages } from "./mediaPreview.js";
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
let scrollToTopBtnEl = null;
let globalClickRegistered = false;
let renderAnimationFrameId = null;
let activeTimerInterval = null;
function getScrollContainer() {
if (!store.ui.cardStack) return null;
return store.ui.cardStack.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|| store.ui.cardStack.parentElement
|| store.ui.cardStack;
}
function updateScrollTopBtnVisibility() {
if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return;
const scrollEl = getScrollContainer();
if (scrollEl) {
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) {
if (window.getComputedStyle(viewportEl).position === "static") {
viewportEl.style.position = "relative";
}
viewportEl.appendChild(scrollToTopBtnEl);
}
if (scrollEl.scrollTop > 200) {
scrollToTopBtnEl.style.display = "flex";
} else {
scrollToTopBtnEl.style.display = "none";
}
}
}
const handleGlobalKeyDown = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.add("comfy-sidebar-ctrl-active");
}
};
const handleGlobalKeyUp = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.remove("comfy-sidebar-ctrl-active");
}
};
const handleGlobalBlur = () => {
document.body.classList.remove("comfy-sidebar-ctrl-active");
};
const handleGlobalClick = (e) => {
if (!store.ui.activeSubmenuPromptId && !store.ui.activeSubmenuBatchImages) {
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
return;
}
const sidebar = store.ui.sidebarContainer;
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay');
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
store.closeSubmenu();
resetAllCardHoverStates();
renderSidebar();
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
}
};
export function setupSidebarView() {
window.addEventListener("keydown", handleGlobalKeyDown);
window.addEventListener("keyup", handleGlobalKeyUp);
window.addEventListener("blur", handleGlobalBlur);
const container = document.createElement("div");
store.ui.sidebarContainer = container;
Object.assign(container.style, {
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative"
});
const header = document.createElement("div");
header.className = "comfy-sidebar-header-root";
Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" });
const standardHeader = document.createElement("div");
standardHeader.className = "comfy-sidebar-standard-header";
Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
const titleGroup = document.createElement("div");
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
searchIcon.title = "Search History";
const title = document.createElement("h3");
title.textContent = "Queue";
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
titleGroup.appendChild(searchIcon);
titleGroup.appendChild(title);
standardHeader.appendChild(titleGroup);
const actionsGroup = document.createElement("div");
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
const btn = document.createElement("button");
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
let timeout = null, isPending = false;
const reset = () => {
isPending = false;
Object.assign(btn.style, {
color: "var(--desc-color, #aaa)", background: "transparent",
borderColor: "var(--border-color, #555)", boxShadow: "none"
});
if (timeout) { clearTimeout(timeout); timeout = null; }
};
btn.addEventListener("click", async (ev) => {
ev.stopPropagation();
if (!isPending) {
isPending = true;
Object.assign(btn.style, {
color: "#fff", background: hoverColor, borderColor: hoverColor,
boxShadow: `0 0 8px ${hoverColor}80`
});
timeout = setTimeout(reset, 1500);
} else {
reset();
await onClickFn();
}
});
return btn;
};
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
await clearCancelledOrFailed();
renderSidebar();
});
const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => {
await clearAllHistory();
renderSidebar();
});
actionsGroup.appendChild(btnClearInterrupted);
actionsGroup.appendChild(btnClearAll);
standardHeader.appendChild(actionsGroup);
const searchContainer = document.createElement("div");
searchContainer.className = "comfy-sidebar-search-container";
Object.assign(searchContainer.style, {
display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)",
border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px",
boxSizing: "border-box", height: "26px"
});
const searchInputIcon = document.createElement("span");
searchInputIcon.className = "pi pi-search";
Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" });
const searchInput = document.createElement("input");
Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." });
Object.assign(searchInput.style, {
flex: "1", background: "transparent", border: "none", outline: "none",
color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
});
const clearSearchBtn = document.createElement("span");
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
clearSearchBtn.title = "Clear & Close Search";
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
searchContainer.appendChild(searchInputIcon);
searchContainer.appendChild(searchInput);
searchContainer.appendChild(clearSearchBtn);
header.appendChild(standardHeader);
header.appendChild(searchContainer);
container.appendChild(header);
searchIcon.addEventListener("click", (e) => {
e.stopPropagation();
standardHeader.style.display = "none";
searchContainer.style.display = "flex";
searchInput.focus();
});
const closeSearch = () => {
searchInput.value = "";
store.setSearchQuery("");
searchContainer.style.display = "none";
standardHeader.style.display = "flex";
renderSidebar();
};
clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); });
searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); });
searchInput.addEventListener("input", () => {
store.setSearchQuery(searchInput.value.trim());
renderSidebar();
});
const cardStack = document.createElement("div");
store.ui.cardStack = cardStack;
Object.assign(cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" });
container.appendChild(cardStack);
scrollToTopBtnEl = document.createElement("button");
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
scrollToTopBtnEl.title = "Scroll to Top";
scrollToTopBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer();
if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
});
container.appendChild(scrollToTopBtnEl);
document.addEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
new ResizeObserver((entries) => {
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
if (store.ui.cardStack) {
store.ui.cardStack.style.columnCount = cols.toString();
store.ui.cardStack.style.columnGap = cols > 1 ? "12px" : "0";
}
}).observe(container);
activeTimerInterval = setInterval(() => {
const activePid = store.ui.currentlyActivePromptId;
if (!activePid) return;
const prompt = store.getPrompt(activePid);
if (prompt && prompt.status === PromptStatus.ACTIVE && prompt.startTime) {
const cardObj = cardPool.get(activePid);
if (cardObj && cardObj.timerEl) {
cardObj.timerEl.textContent = ((Date.now() - prompt.startTime) / 1000).toFixed(2) + "s";
}
}
}, 250);
store.on(StoreEvents.QUEUE_SYNCED, () => {
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
const runningCount = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE).length;
updateSidebarBadge(pendingCount + (runningCount > 0 ? 1 : 0));
renderSidebar();
});
store.on(StoreEvents.PROMPT_ADDED, () => renderSidebar());
store.on(StoreEvents.PROMPT_DELETED, () => renderSidebar());
store.on(StoreEvents.PROMPTS_CLEARED, () => renderSidebar());
store.on(StoreEvents.SUBMENU_CHANGED, () => renderSidebar());
return container;
}
export function renderSidebar() {
if (renderAnimationFrameId) cancelAnimationFrame(renderAnimationFrameId);
renderAnimationFrameId = requestAnimationFrame(() => {
if (!store.ui.sidebarContainer || !store.ui.cardStack) return;
const showPendingSummary = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_PENDING_COUNT_ONLY) ?? true;
const showWorkingNode = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
const headerTitle = store.ui.sidebarContainer.querySelector("h3");
const headerSearchIcon = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-root .pi-search");
const headerActions = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-btn")?.parentNode;
// Submenu 1: Batch Images View
if (store.ui.activeSubmenuBatchImages) {
const batchInfo = store.ui.activeSubmenuBatchImages;
if (headerTitle) {
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back";
headerTitle.onclick = () => {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
}
resetAllCardHoverStates();
renderSidebar();
};
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
if (!globalClickRegistered) {
document.addEventListener("click", handleGlobalClick, true);
globalClickRegistered = true;
}
const targetElements = [];
batchInfo.images.forEach((img, index) => {
const cardId = `batch-${batchInfo.pid}-${index}`;
let cardObj = cardPool.get(cardId);
if (!cardObj) {
const card = document.createElement("div");
card.id = `card-${cardId}`;
card.className = "comfy-sidebar-card completed";
card.style.position = "relative";
const timerEl = document.createElement("div");
timerEl.className = "comfy-sidebar-card-timer";
timerEl.textContent = `Image ${index + 1}/${batchInfo.images.length}`;
timerEl.style.display = "block";
const dimEl = document.createElement("div");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
btnImg.onclick = (ev) => {
ev.stopPropagation();
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
a.download = img.filename || "output";
a.click();
};
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing from batch";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
if (shouldDeleteFromDisk && img && img.filename) {
await deleteFileOnServer(img);
}
const pid = batchInfo.parentPromptId || batchInfo.pid;
const state = store.getPrompt(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
}
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
}
state.rendered = false;
store.updatePrompt(pid, state);
}
const mainCardObj = cardPool.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
if (batchInfo.images.length === 0) {
if (batchInfo.parentPromptId) {
store.openOutputsSubmenu(batchInfo.parentPromptId);
} else {
store.closeSubmenu();
}
}
renderSidebar();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
const isCopyable = isImageFormat(img.filename || img.url);
if (!isCopyable) {
btnCopy.style.display = "none";
} else {
btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const success = await copyImageToClipboard(src);
if (success) {
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
btnCopy.style.color = "#4ade80";
setTimeout(() => {
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.style.color = "";
}, 1500);
}
};
}
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
const nodeId = findNodeIdForImage({ nodeOutputs: batchInfo.nodeOutputs }, img);
if (nodeId) {
btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(nodeId);
};
} else {
btnFocus.style.display = "none";
}
leftHoverPanel.append(btnCopy, btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
timerEl,
dimEl,
grid,
placeholder: p,
hoverPanel,
leftHoverPanel,
btnImg,
btnCopy,
btnFocus,
btnDel,
firstImgElement: null
};
cardPool.set(cardId, cardObj);
}
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, {
pid: batchInfo.pid,
status: PromptStatus.COMPLETED,
images: [img],
workflow: batchInfo.workflow,
nodeOutputs: batchInfo.nodeOutputs
});
targetElements.push(cardObj.element);
});
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
}
const scrollEl = getScrollContainer();
if (scrollEl) scrollEl.scrollTop = 0;
updateScrollTopBtnVisibility();
return;
}
// Submenu 2: Intermediate Outputs View
if (store.ui.activeSubmenuPromptId) {
const st = store.getPrompt(store.ui.activeSubmenuPromptId);
if (!st) {
store.closeSubmenu();
renderSidebar();
return;
}
if (headerTitle) {
headerTitle.textContent = `Outputs of #${store.ui.activeSubmenuPromptId}`;
headerTitle.style.cursor = "pointer";
headerTitle.title = "Go Back to Queue";
headerTitle.onclick = () => {
store.closeSubmenu();
resetAllCardHoverStates();
renderSidebar();
};
}
if (headerSearchIcon) headerSearchIcon.style.display = "none";
if (headerActions) headerActions.style.display = "none";
if (!globalClickRegistered) {
document.addEventListener("click", handleGlobalClick, true);
globalClickRegistered = true;
}
const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
const seenFiles = new Set();
const outputs = [];
rawOutputs.forEach(out => {
const uniqueImgs = (out.images || []).filter(img => {
const key = `${img.subfolder || ""}/${img.filename}`;
if (seenFiles.has(key)) return false;
seenFiles.add(key);
return true;
});
if (uniqueImgs.length > 0) {
outputs.push({ nodeId: out.nodeId, images: uniqueImgs, nodeTitle: out.nodeTitle });
}
});
const targetElements = [];
if (outputs.length === 0) {
const emptyCard = document.createElement("div");
emptyCard.className = "comfy-sidebar-card completed";
Object.assign(emptyCard.style, {
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px"
});
emptyCard.textContent = "No separate intermediate outputs found.";
targetElements.push(emptyCard);
} else {
outputs.forEach((out) => {
const cardId = `submenu-${st.pid}-${out.nodeId}`;
let cardObj = cardPool.get(cardId);
if (!cardObj) {
const card = document.createElement("div");
card.id = `card-${cardId}`;
card.className = "comfy-sidebar-card completed";
card.style.position = "relative";
const titleFromState = st.nodeTitles?.[out.nodeId];
const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId));
const resolvedTitle = titleFromState || (node ? (node.title || node.type) : out.nodeTitle) || "Node";
const timerEl = document.createElement("div");
timerEl.className = "comfy-sidebar-card-timer";
timerEl.textContent = `${resolvedTitle} (#${out.nodeId})`;
timerEl.style.display = "block";
const dimEl = document.createElement("div");
Object.assign(dimEl.style, {
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
display: "none", transform: "translateZ(0)"
});
const grid = document.createElement("div");
grid.style.display = "flex";
grid.style.flexDirection = "column";
grid.style.gap = "6px";
const p = document.createElement("div");
p.className = "comfy-sidebar-text-clamp";
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
btnImg.onclick = (ev) => {
ev.stopPropagation();
out.images.forEach(img => {
const a = document.createElement("a");
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
a.download = img.filename || "output";
a.click();
});
};
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete this node's files from DISK / TRASH"
: "Click again to confirm removing output";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
if (shouldDeleteFromDisk && out.images) {
for (const imgItem of out.images) {
await deleteFileOnServer(imgItem);
}
}
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
store.closeSubmenu();
}
store.updatePrompt(st.pid, st);
renderSidebar();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
const btnCopy = document.createElement("span");
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.title = "Copy to Clipboard";
const firstImg = out.images[0];
const isCopyable = firstImg && isImageFormat(firstImg.filename || firstImg.url);
if (!isCopyable) {
btnCopy.style.display = "none";
} else {
btnCopy.onclick = async (ev) => {
ev.stopPropagation();
const src = firstImg.url ? firstImg.url : `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}`;
const success = await copyImageToClipboard(src);
if (success) {
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
btnCopy.style.color = "#4ade80";
setTimeout(() => {
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
btnCopy.style.color = "";
}, 1500);
}
};
}
const btnFocus = document.createElement("span");
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
btnFocus.title = "Show Node";
btnFocus.onclick = (ev) => {
ev.stopPropagation();
centerAndSelectCanvasNode(out.nodeId);
};
leftHoverPanel.append(btnCopy, btnFocus);
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
cardObj = {
element: card,
timerEl,
dimEl,
grid,
placeholder: p,
hoverPanel,
leftHoverPanel,
btnImg,
btnCopy,
btnFocus,
btnDel,
firstImgElement: null
};
cardPool.set(cardId, cardObj);
}
if (out.images && out.images.length > 0) {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, {
pid: st.pid,
status: PromptStatus.COMPLETED,
images: out.images,
workflow: st.workflow,
nodeOutputs: st.nodeOutputs
}, () => {
// Clicking (1/N) opens batch drill-down for this specific intermediate node
const scrollEl = getScrollContainer();
if (scrollEl) {
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
}
const nodeTitle = st.nodeTitles?.[out.nodeId] || out.nodeTitle || "Node";
store.openBatchSubmenu({
pid: `${st.pid} - ${nodeTitle} (#${out.nodeId})`,
parentPromptId: st.pid,
images: out.images,
workflow: st.workflow,
nodeOutputs: { [out.nodeId]: st.nodeOutputs?.[out.nodeId] }
});
});
} else {
cardObj.placeholder.style.display = "block";
cardObj.placeholder.textContent = "No Outputs";
}
targetElements.push(cardObj.element);
});
}
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
}
updateScrollTopBtnVisibility();
return;
}
// Main Queue View
if (headerTitle) {
headerTitle.textContent = "Queue";
headerTitle.style.cursor = "default";
headerTitle.onclick = null;
}
if (headerSearchIcon) headerSearchIcon.style.display = "inline";
if (headerActions) headerActions.style.display = "flex";
let tasksArray = store.getAllPrompts();
if (showPendingSummary) {
tasksArray = tasksArray.filter(t => t.status !== PromptStatus.PENDING);
}
if (store.ui.searchQuery) {
tasksArray = tasksArray.filter(t => matchesFilter(t, store.ui.searchQuery));
}
tasksArray.sort((a, b) => b.timestamp - a.timestamp);
const activeTasks = tasksArray.filter(t => t.status === PromptStatus.ACTIVE);
const completedTasks = tasksArray.filter(t => t.status === PromptStatus.COMPLETED || t.status === PromptStatus.CANCELLED || t.status === PromptStatus.ERROR);
const pendingTasks = tasksArray.filter(t => t.status === PromptStatus.PENDING).sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0));
const syncTaskCard = (state) => {
const cardObj = getOrCreateCard(state);
return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode);
};
const targetElements = [];
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
if (pendingCount > 0) {
if (showPendingSummary) {
let pCard = cardPool.get("pending-summary-card");
if (!pCard) {
const el = document.createElement("div");
Object.assign(el.style, {
background: "var(--comfy-input-bg, #181818)", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px",
marginBottom: "12px", textAlign: "center", fontSize: "12px", fontWeight: "bold",
color: "var(--desc-color, #aaa)", breakInside: "avoid", display: "flex", flexDirection: "column", gap: "8px"
});
const textDiv = document.createElement("div");
el.appendChild(textDiv);
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel All Pending";
Object.assign(cancelBtn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold"
});
cancelBtn.onclick = async () => {
await cancelAllPending();
};
el.appendChild(cancelBtn);
pCard = { element: el, textDiv };
cardPool.set("pending-summary-card", pCard);
}
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
targetElements.push(pCard.element);
} else {
let btnCard = cardPool.get("pending-cancel-all-standalone");
if (!btnCard) {
const btn = document.createElement("button");
btn.textContent = "Cancel All Pending";
Object.assign(btn.style, {
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px",
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%", marginBottom: "12px", breakInside: "avoid"
});
btn.onclick = async () => {
await cancelAllPending();
};
btnCard = { element: btn };
cardPool.set("pending-cancel-all-standalone", btnCard);
}
targetElements.push(btnCard.element);
pendingTasks.forEach(st => targetElements.push(syncTaskCard(st)));
}
}
activeTasks.forEach(st => targetElements.push(syncTaskCard(st)));
completedTasks.forEach(st => targetElements.push(syncTaskCard(st)));
targetElements.forEach((el, index) => {
if (store.ui.cardStack.children[index] !== el) {
store.ui.cardStack.insertBefore(el, store.ui.cardStack.children[index] || null);
}
});
while (store.ui.cardStack.children.length > targetElements.length) {
store.ui.cardStack.removeChild(store.ui.cardStack.lastChild);
}
if (store.ui.mainQueueScrollTop !== null) {
const restorePos = store.ui.mainQueueScrollTop;
store.ui.mainQueueScrollTop = null;
const scrollEl = getScrollContainer();
if (scrollEl) {
scrollEl.scrollTop = restorePos;
requestAnimationFrame(() => { scrollEl.scrollTop = restorePos; });
}
}
updateScrollTopBtnVisibility();
});
}
export function teardownSidebarView() {
window.removeEventListener("keydown", handleGlobalKeyDown);
window.removeEventListener("keyup", handleGlobalKeyUp);
window.removeEventListener("blur", handleGlobalBlur);
document.removeEventListener("click", handleGlobalClick, true);
document.removeEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
if (activeTimerInterval) {
clearInterval(activeTimerInterval);
activeTimerInterval = null;
}
if (renderAnimationFrameId) {
cancelAnimationFrame(renderAnimationFrameId);
renderAnimationFrameId = null;
}
cardPool.clear();
}
-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;
}
+763
View File
@@ -0,0 +1,763 @@
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";
let activeComparisonViewer = null;
export function isAudioViewerOpen() {
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
}
export function updateActiveComparisonPreview(pid, newSrc) {
if (activeComparisonViewer && activeComparisonViewer.targetPid === String(pid)) {
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,
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"
});
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,
onKeyDown: (e) => {
if (e.key === " " && mediaB) {
e.preventDefault();
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
}
});
const scrollContainer = document.createElement("div");
Object.assign(scrollContainer.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
display: "flex", overflow: "auto", boxSizing: "border-box",
padding: "48px 24px", scrollbarWidth: "thin",
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
});
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"
});
scrollContainer.appendChild(wrapper);
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
let isZoomed = false;
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 (!mediaB) {
slider.style.display = "none";
if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto";
if (isZoomed && wA && hA) {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${wA}px`;
wrapper.style.height = `${hA}px`;
wrapper.style.cursor = "zoom-out";
mediaA.style.maxWidth = "none";
mediaA.style.maxHeight = "none";
mediaA.style.width = "100%";
mediaA.style.height = "100%";
mediaA.style.objectFit = "fill";
} else {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
mediaA.style.maxWidth = "calc(100vw - 120px)";
mediaA.style.maxHeight = "calc(100vh - 120px)";
mediaA.style.width = "auto";
mediaA.style.height = "auto";
mediaA.style.objectFit = "contain";
}
return;
}
const wB = mediaB.naturalWidth || mediaB.videoWidth || 0;
const hB = mediaB.naturalHeight || mediaB.videoHeight || 0;
if (!wA || !hA || !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";
if (mediaA) mediaA.style.pointerEvents = "none";
if (mediaB) mediaB.style.pointerEvents = "none";
infoText.style.color = "#aaa";
infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card to compare | Esc to close.";
updateSliderPosition(50);
const maxW = Math.max(wA, wB);
const maxH = Math.max(hA, hB);
if (isZoomed) {
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = `${maxW}px`;
wrapper.style.height = `${maxH}px`;
wrapper.style.cursor = "zoom-out";
[mediaA, mediaB].forEach(el => {
if (el) {
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 = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = "zoom-in";
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "calc(100vw - 120px)";
el.style.maxHeight = "calc(100vh - 120px)";
el.style.width = "auto";
el.style.height = "auto";
el.style.objectFit = "contain";
}
});
}
};
mediaA.onload = syncImageScales;
if (mediaA.complete) syncImageScales();
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);
if (!dragMoved && (Math.abs(clientX - dragStartX) > 4 || Math.abs(clientY - dragStartY) > 4)) {
dragMoved = true;
}
if (dragMoved && mediaB) {
const rect = wrapper.getBoundingClientRect();
const percent = ((clientX - rect.left) / rect.width) * 100;
updateSliderPosition(percent);
}
};
const endDrag = () => {
if (!isDraggingSlider) return;
isDraggingSlider = false;
if (dragMoved) setTimeout(() => { dragMoved = false; }, 50);
};
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", () => {
if (dragMoved || isBaseVideo) return;
toggleZoom();
});
scrollContainer.addEventListener("click", (e) => {
if (dragMoved) return;
if (e.target === scrollContainer || e.target === overlay.container) 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) {
overlay.destroy();
showFullscreenPreview([targetSrc], isShiftClick);
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";
mediaA.src = targetSrc;
mediaA.onload = () => {
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];
// Handle Text Output
if (typeof item === "object" && item.text) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isText) {
activeComparisonViewer.loadTarget(item);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = createTextReader(
item,
(target) => showFullscreenPreview([target]),
() => { activeComparisonViewer = null; }
);
return;
}
const src = typeof item === "string" ? item : (item.url || item.filename || "");
// Delegate 3D formats
if (is3DFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.is3D) {
activeComparisonViewer.loadTarget(src);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = create3DViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc]),
() => { activeComparisonViewer = null; }
);
return;
}
// Delegate Audio formats
if (isAudioFormat(src)) {
if (activeComparisonViewer) {
if (activeComparisonViewer.isAudio) {
activeComparisonViewer.loadTarget(src);
return;
}
activeComparisonViewer.destroy();
activeComparisonViewer = null;
}
activeComparisonViewer = createAudioViewer(
src,
(targetSrc) => showFullscreenPreview([targetSrc]),
() => { activeComparisonViewer = null; }
);
return;
}
// Delegate 2D/Video formats
if (activeComparisonViewer) {
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
activeComparisonViewer.destroy();
activeComparisonViewer = null;
} else {
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
}
activeComparisonViewer = createComparisonViewer(
src,
() => { activeComparisonViewer = null; }
);
if (activeComparisonViewer) {
activeComparisonViewer.targetPid = pid ? String(pid) : null;
}
}
+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);
};
}
+114
View File
@@ -0,0 +1,114 @@
import { store } from "../core/store.js";
export class SidebarOverlay {
constructor(options = {}) {
this.onDestroy = options.onDestroy || (() => {});
this.cleanupFns = [];
// Root container
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: "auto", userSelect: "none"
});
// 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"
});
this.closeBtn.onmouseenter = () => {
this.closeBtn.style.color = "var(--fg-color, #fff)";
this.closeBtn.style.borderColor = "var(--fg-color, #fff)";
};
this.closeBtn.onmouseleave = () => {
this.closeBtn.style.color = "var(--desc-color, #aaa)";
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
};
this.closeBtn.onclick = () => this.destroy();
this.container.appendChild(this.closeBtn);
// Backdrop click to close
this.container.addEventListener("click", (e) => {
if (e.target === this.container) this.destroy();
});
// Sidebar boundary alignment
this.updateOverlayBounds = this.updateOverlayBounds.bind(this);
this.updateOverlayBounds();
window.addEventListener("resize", this.updateOverlayBounds);
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
if (sidebarEl && window.ResizeObserver) {
const ro = new ResizeObserver(this.updateOverlayBounds);
ro.observe(sidebarEl);
this.cleanupFns.push(() => ro.disconnect());
}
// Global Key Handler (Esc to close)
const onKeyDown = (e) => {
if (e.key === "Escape") {
e.preventDefault();
this.destroy();
}
if (options.onKeyDown) options.onKeyDown(e);
};
document.addEventListener("keydown", onKeyDown);
this.cleanupFns.push(() => document.removeEventListener("keydown", onKeyDown));
document.body.appendChild(this.container);
}
updateOverlayBounds() {
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
if (sidebarEl && sidebarEl.offsetWidth > 0 && sidebarEl.isConnected) {
const rect = sidebarEl.getBoundingClientRect();
if (rect.left < window.innerWidth / 2) {
const leftOffset = Math.max(0, rect.right);
this.container.style.left = `${leftOffset}px`;
this.container.style.width = `calc(100vw - ${leftOffset}px)`;
this.container.style.right = "0px";
} else {
const rightOffset = Math.max(0, window.innerWidth - rect.left);
this.container.style.left = "0px";
this.container.style.width = `calc(100vw - ${rightOffset}px)`;
this.container.style.right = `${rightOffset}px`;
}
this.container.style.top = `${Math.max(0, rect.top)}px`;
this.container.style.height = `calc(100vh - ${Math.max(0, rect.top)}px)`;
} else {
this.container.style.left = "0px";
this.container.style.width = "100vw";
this.container.style.top = "0px";
this.container.style.height = "100vh";
}
}
addCleanup(fn) {
this.cleanupFns.push(fn);
}
destroy() {
for (const fn of this.cleanupFns.splice(0)) {
try { fn(); } catch (e) {}
}
this.container.remove();
this.onDestroy();
}
}
+517
View File
@@ -0,0 +1,517 @@
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: 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;
}
/* Prevent action hover buttons (Delete/JSON/Node) 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: 5; 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;
}
.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 / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
.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);
}
`;
document.head.appendChild(style);
}
+336
View File
@@ -0,0 +1,336 @@
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)"
});
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" });
const titleInfo = document.createElement("div");
Object.assign(titleInfo.style, { display: "flex", alignItems: "center", gap: "10px" });
const title = document.createElement("span");
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f8fafc", whiteSpace: "nowrap" });
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" });
titleInfo.append(title, statsBadge);
const actionsRow = document.createElement("div");
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center" });
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 = 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;
}
+568
View File
@@ -0,0 +1,568 @@
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 {
let loaded = null;
// 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes)
try {
loaded = await loadFromBase("/comfy-sidebar/three-proxy/");
window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/";
} catch (proxyErr) {
// 2. Fall back to direct CDN if proxy is unreachable
console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr);
loaded = await loadFromBase("https://esm.sh/");
window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/";
}
const { three, controls, gltf, obj, stl, ply } = loaded;
cachedThreeLibs = {
...three,
OrbitControls: controls.OrbitControls || 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 || "/comfy-sidebar/three-proxy/";
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: 320px; line-height: 1.4;">
<span style="color: #f87171; font-weight: bold;">Failed to load 3D engine.</span><br>
<span style="font-size: 11px; color: #94a3b8;">Blocked by ComfyUI Content Security Policy (CSP). Check if <code>--disable-api-nodes</code> is enabled in your startup arguments, or vendor Three.js locally.</span>
</div>
`;
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()
};
}
+13
View File
@@ -0,0 +1,13 @@
[project]
name = "comfy-sidebar"
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
version = "1.5.1"
license = { file = "LICENSE" }
dependencies = []
[project.urls]
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
[tool.comfy]
PublisherId = "soundslikethunder"
DisplayName = "Comfy Sidebar"