Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17314673e | ||
|
|
8343f216fc | ||
|
|
3e7448dd80 | ||
|
|
b93413a729 | ||
|
|
782d30b490 | ||
|
|
0a2db6b85f | ||
|
|
5a4c9f99a5 | ||
|
|
141a0872d5 | ||
|
|
108ac61bec | ||
|
|
b31cfb5f8a | ||
|
|
fe154b7787 | ||
|
|
da5756d61c | ||
|
|
370f4fc58c | ||
|
|
73a9092001 | ||
|
|
9797d1ac7a | ||
|
|
d0424d0d05 | ||
|
|
c72407690c | ||
|
|
31c994a1b6 | ||
|
|
9219f2d042 | ||
|
|
b3e6b1bd62 | ||
|
|
2dc92d3e6f | ||
|
|
e83c999da7 |
@@ -1,4 +1,5 @@
|
||||
name: Publish to Comfy registry
|
||||
name: Publish to Comfy registry & GitHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,10 +12,31 @@ 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 }}
|
||||
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
@@ -1,2 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyc
|
||||
js/vendor/
|
||||
@@ -1,20 +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 `Shift+Click` another to load it alongside the first one for pixel by pixel comparison.
|
||||
* **[NEW]** Prevent selected nodes from showing the output in the queue bar and intermediate results. `Ctrl+Q` to toggle.
|
||||
* 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" />
|
||||
|
||||
@@ -33,10 +37,32 @@ Restart your ComfyUI server and refresh the browser tab.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
See additional tweaks in ComfyUI settings menu > Comfy Sidebar. From there, you can adjust the layout or fully replace the stock V2 panel by enabling options like **"Comfy Layout"** and **"Replace the stock Job History sidebar with Comfy Queue"**.
|
||||
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.
|
||||
* **`Ctrl+Q on a selected node`**: Sets the node to be ignored in the sidebar panel results.
|
||||
* **`Click`** on a card to enter preview. **`Shift+Click`** on another card in preview to enter compare mode.
|
||||
| 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
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
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 = `
|
||||
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
|
||||
.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: -5px !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;
|
||||
@@ -23,7 +40,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Style overrides for the new native Extensions button container when embedded in top actionbar */
|
||||
.comfy-sidebar-extensions-override {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -37,7 +53,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Scale down performance indicators (Crystools) uniformly */
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
@@ -46,7 +61,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Hide user account profile button and PrimeVue components */
|
||||
img[alt*="User Avatar"],
|
||||
img[alt*="user avatar"],
|
||||
button:has(img[alt*="User Avatar"]),
|
||||
@@ -62,14 +76,12 @@ button:has([data-pc-name="avatar"]),
|
||||
display: none !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"] {
|
||||
@@ -78,7 +90,6 @@ button:has([data-pc-name="avatar"]),
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -87,7 +98,6 @@ button:has([data-pc-name="avatar"]),
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -98,45 +108,21 @@ button:has([data-pc-name="avatar"]),
|
||||
`;
|
||||
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
if (!app.ui?.settings) return;
|
||||
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
|
||||
try {
|
||||
app.ui.settings.setSettingValue(QPOV2_ID, dockedVal);
|
||||
localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const qpovContainer = document.querySelector('[data-setting-id="Comfy.Queue.QPOV2"]') ||
|
||||
document.getElementById(QPOV2_ID)?.closest('.setting-item, tr, div');
|
||||
if (qpovContainer) {
|
||||
const toggle = qpovContainer.querySelector('input[type="checkbox"], [role="switch"], .p-toggleswitch');
|
||||
if (toggle) {
|
||||
const isChecked = toggle.checked ||
|
||||
toggle.getAttribute('data-p-checked') === 'true' ||
|
||||
toggle.classList.contains('p-toggleswitch-checked');
|
||||
if (isChecked !== dockedVal) toggle.click();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
try {
|
||||
app.ui.settings.setSettingValue(progressKey, progressVal);
|
||||
localStorage.setItem(progressKey, JSON.stringify(progressVal));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const progressBarBtn = document.querySelector('[data-testid="show-run-progress-bar-action"]');
|
||||
if (progressBarBtn) {
|
||||
const isChecked = !!progressBarBtn.querySelector('[class*="lucide--check"], .icon-\\[lucide--check\\]');
|
||||
if (isChecked !== progressVal) progressBarBtn.click();
|
||||
}
|
||||
} catch (e) {}
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -144,8 +130,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (updateSetting && app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
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");
|
||||
@@ -155,8 +145,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
if (updateSetting && app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,93 +159,6 @@ let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
// 1. Direct check via data-testid
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Fallback check for visible headings/containers
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback check for original button active state
|
||||
const origBtn = findOriginalButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
@@ -260,7 +167,7 @@ function updateSidebarTabsVisibility() {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"]',
|
||||
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
@@ -290,24 +197,6 @@ function updateSidebarTabsVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
|
||||
for (const el of elements) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) continue;
|
||||
return el;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
@@ -369,7 +258,7 @@ export function syncClassicLayout() {
|
||||
}
|
||||
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
@@ -384,7 +273,7 @@ export function syncClassicLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalButton();
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
@@ -421,7 +310,7 @@ export function syncClassicLayout() {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
const nativeBtn = findOriginalPropertiesButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
@@ -491,7 +380,6 @@ export function setupPropertiesPanelToggleFix() {
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
/* Suppress stock red dot ONLY when original button is hidden by custom layout */
|
||||
.comfy-sidebar-hide-original-properties-btn ~ span,
|
||||
.comfy-sidebar-hide-original-properties-btn + span {
|
||||
display: none !important;
|
||||
@@ -521,12 +409,20 @@ export function setupPropertiesPanelToggleFix() {
|
||||
|
||||
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 hasElementMutation = Array.from(mutation.addedNodes).some(node => node.nodeType === 1) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1);
|
||||
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 (hasElementMutation) {
|
||||
if (hasExternalElementMutation) {
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
@@ -547,4 +443,15 @@ export function setupPropertiesPanelToggleFix() {
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import { State } from "./state.js";
|
||||
import { isVideoFormat } from "./utils.js";
|
||||
|
||||
let activeComparisonViewer = null;
|
||||
let globalKeydownHandler = null;
|
||||
|
||||
// Safe clientX resolver for mouse and touch events
|
||||
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 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: "100%", maxHeight: "80vh",
|
||||
objectFit: "contain", pointerEvents: "none"
|
||||
});
|
||||
el.src = isVideo ? src + "#t=0.001" : src;
|
||||
return el;
|
||||
};
|
||||
|
||||
const setupVideoPlayback = (vid, container) => {
|
||||
if (!vid) return () => {};
|
||||
|
||||
// Localized animation loop states to prevent cross-instance contamination
|
||||
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: "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 = 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) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
const canvasEl = document.querySelector("#graph-canvas, canvas");
|
||||
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
|
||||
|
||||
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",
|
||||
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 to replace | Shift+Click another card image to compare.";
|
||||
header.appendChild(infoText);
|
||||
container.appendChild(header);
|
||||
|
||||
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);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
maxWidth: "85%", maxHeight: "85%"
|
||||
});
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Bottom tooltip helper (Font size increased to 12px for legibility)
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", zIndex: "30",
|
||||
color: "#888", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#aaa;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
hintPrompt.style.display = "none";
|
||||
}
|
||||
container.appendChild(hintPrompt);
|
||||
|
||||
// Track drag actions to prevent mouseup outside the slider from triggering modal exit
|
||||
let wasDragging = false;
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
if (wasDragging) {
|
||||
wasDragging = false; // Ignore synthetic click caused by releasing drag on backdrop
|
||||
return;
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
|
||||
let mediaB = null;
|
||||
|
||||
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;
|
||||
let isDragging = false;
|
||||
let destroyVideoPlaybackFn = null;
|
||||
|
||||
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 startDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
wasDragging = false;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = getClientX(e);
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const doDrag = (e) => {
|
||||
if (!isDragging) return;
|
||||
wasDragging = true;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = getClientX(e);
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const endDrag = () => {
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
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 cleanupMedia = (el) => {
|
||||
if (el && el.tagName === "VIDEO") {
|
||||
el.pause();
|
||||
el.src = "";
|
||||
el.load();
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
window.removeEventListener("mousemove", doDrag);
|
||||
window.removeEventListener("touchmove", doDrag);
|
||||
window.removeEventListener("mouseup", endDrag);
|
||||
window.removeEventListener("touchend", endDrag);
|
||||
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
|
||||
|
||||
cleanupMedia(mediaA);
|
||||
cleanupMedia(mediaB);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
targetContainer.appendChild(container);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
}
|
||||
|
||||
return {
|
||||
loadTarget(targetSrc, isShiftClick) {
|
||||
const isTargetVideo = isVideoFormat(targetSrc);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isShiftClick) {
|
||||
if (mediaB) {
|
||||
cleanupMedia(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. Shift+Click other card images to update target | Esc to close.";
|
||||
updateSliderPosition(50);
|
||||
} else {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
mediaA.src = targetSrc;
|
||||
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
Vendored
-114
@@ -1,114 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { State } from "./state.js";
|
||||
|
||||
async function uploadDroppedImageToInput(imageObj) {
|
||||
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
if (!response.ok) return null;
|
||||
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 });
|
||||
if (!uploadRes.ok) return null;
|
||||
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 isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
|
||||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
|
||||
if (isSidebarDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
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"))) {
|
||||
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 {
|
||||
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 || "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);
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage, cardElements } 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);
|
||||
|
||||
// FIX: Revoke live preview blob URL to prevent browser memory leaks
|
||||
if (st._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
delete st._previewBlobUrl;
|
||||
}
|
||||
|
||||
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;
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow);
|
||||
st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow);
|
||||
}
|
||||
} catch (err) {}
|
||||
pruneHistory(app);
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
|
||||
const toDelete = [];
|
||||
for (const [p, s] of promptStates.entries()) {
|
||||
if (s.status === "cancelled" || s.status === "error") {
|
||||
toDelete.push(p);
|
||||
promptStates.delete(p);
|
||||
}
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalImgs.length > 0) {
|
||||
st.images = finalImgs;
|
||||
}
|
||||
const finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalTexts.length > 0) {
|
||||
st.texts = finalTexts;
|
||||
}
|
||||
|
||||
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) {
|
||||
promptStates.clear();
|
||||
cardElements.clear();
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
|
||||
} else if (!storedSessionId && 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 workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null;
|
||||
const images = findImagesInOutputs(historyData[id].outputs, workflow);
|
||||
const texts = findTextsInOutputs(historyData[id].outputs, workflow);
|
||||
if (images.length === 0 && texts.length === 0) return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(id, {
|
||||
pid: id, status: "completed", images, texts,
|
||||
nodeOutputs: historyData[id].outputs,
|
||||
workflow: workflow,
|
||||
progressText: "", timestamp: State.globalOrderCounter, rendered: true
|
||||
});
|
||||
});
|
||||
pruneHistory(app);
|
||||
await syncQueue();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
-250
@@ -1,250 +1,224 @@
|
||||
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, syncStockHistoryAndProgressSettings } from "./layout.js";
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
let nodes = [];
|
||||
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
|
||||
nodes = Object.values(canvas.selected_nodes);
|
||||
} else if (canvas.current_active_node) {
|
||||
nodes = [canvas.current_active_node];
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.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 });
|
||||
|
||||
// NodesMap removed from sidebarTabs list
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui && app.ui.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
setupPropertiesPanelToggleFix();
|
||||
|
||||
setSyncQueue(syncQueue);
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
injectStyles();
|
||||
setupDragAndDrop();
|
||||
|
||||
const sidebarContainer = setupSidebarUI();
|
||||
|
||||
setupApiListeners();
|
||||
|
||||
await initSessionAndHistory();
|
||||
|
||||
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.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.preventDefault(); e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
}, true);
|
||||
|
||||
setInterval(() => {
|
||||
applySidebarOverride();
|
||||
|
||||
if (app.graph && app.graph._nodes) {
|
||||
const hasIgnoredOrColoredNodes = app.graph._nodes.some(n => n.properties?.ignoreInQueue || n.boxcolor === "#ff3333");
|
||||
const hasBadgesInDOM = !!document.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (hasIgnoredOrColoredNodes || hasBadgesInDOM) {
|
||||
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";
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
export const promptStates = new Map();
|
||||
export const cardElements = new Map();
|
||||
|
||||
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;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
serializable.push({
|
||||
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
loadStatesFromLocalStorage();
|
||||
@@ -1,91 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Optimized Card Action Buttons */
|
||||
.comfy-sidebar-card-action-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 14px !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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
const s = url.toLowerCase();
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
};
|
||||
|
||||
function isNodeIgnored(nodeId, workflow) {
|
||||
if (!workflow || !workflow.nodes) {
|
||||
// Fallback to active graph if workflow is missing (e.g. during live execution)
|
||||
const liveNode = app.graph?.getNodeById(Number(nodeId));
|
||||
if (liveNode) {
|
||||
return !!(liveNode.properties && liveNode.properties.ignoreInQueue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
|
||||
return !!(node && node.properties && node.properties.ignoreInQueue);
|
||||
}
|
||||
|
||||
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
|
||||
export function findImagesInOutputs(outputs, workflow) {
|
||||
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 (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, workflow) {
|
||||
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') {
|
||||
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, workflow) {
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+185
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.2.8"
|
||||
version = "1.5.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user