Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70c8e64c4e | ||
|
|
1f3c3cb598 | ||
|
|
0238cf109d | ||
|
|
869d3b9a28 | ||
|
|
f09e2c7921 | ||
|
|
9adfb2a53f | ||
|
|
a17314673e | ||
|
|
8343f216fc | ||
|
|
3e7448dd80 | ||
|
|
b93413a729 | ||
|
|
782d30b490 | ||
|
|
0a2db6b85f | ||
|
|
5a4c9f99a5 | ||
|
|
141a0872d5 | ||
|
|
108ac61bec | ||
|
|
b31cfb5f8a | ||
|
|
fe154b7787 | ||
|
|
da5756d61c | ||
|
|
370f4fc58c |
@@ -16,7 +16,10 @@ A compact sidebar for ComfyUI that replaces the stock queue/history UI with a fa
|
||||
* 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`.
|
||||
* Exclude selected nodes from sidebar results with `Ctrl+Q` (or the header button).
|
||||
* Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
|
||||
* Ctrl-clicking the delete button places the generated file to the recycle bin.
|
||||
* [NEW] Reset all node IDs in topological execution order (`Alt+R`, right-click menu, or header button) to ensure the final output node is always prioritized as the primary preview.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
@@ -52,9 +55,11 @@ Open **Settings → Comfy Sidebar** to configure:
|
||||
| Shortcut | Action |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `Q` | Toggle ComfySidebar |
|
||||
| `Ctrl+Q` | Toggle sidebar output filtering for selected nodes |
|
||||
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
|
||||
| `Alt+R` | Reset all node IDs by execution order |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open file location/alternative action |
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
|
||||
+199
-4
@@ -1,8 +1,203 @@
|
||||
# Standard ComfyUI extension loader
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from aiohttp import web
|
||||
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"]
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
|
||||
def is_path_safe(base_dir, target_path):
|
||||
"""Prevents Path Traversal attacks."""
|
||||
real_base = os.path.realpath(base_dir)
|
||||
real_target = os.path.realpath(target_path)
|
||||
try:
|
||||
# commonpath reliably handles OS-specific case sensitivity and slash logic
|
||||
return os.path.commonpath([real_base, real_target]) == real_base
|
||||
except ValueError:
|
||||
# Raised if paths are on different drives on Windows
|
||||
return False
|
||||
|
||||
|
||||
def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
"""Gathers all potential ComfyUI base directories (output, model folders, extra paths)."""
|
||||
roots = []
|
||||
try:
|
||||
roots.append(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Include specific model categories (loras, checkpoints, etc.)
|
||||
categories = ["loras", "checkpoints", "vae", "unet", "clip", "embeddings", "upscale_models"]
|
||||
if subfolder and subfolder in folder_paths.folder_names_and_paths:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(subfolder))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cat in categories:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(cat))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
roots.append(folder_paths.get_input_directory())
|
||||
roots.append(folder_paths.get_temp_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplicate while preserving existence
|
||||
unique = []
|
||||
for r in roots:
|
||||
if r and r not in unique and os.path.exists(r):
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output", candidate_roots=None):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories."""
|
||||
candidates = candidate_roots if candidate_roots is not None else get_all_candidate_roots(subfolder, folder_type)
|
||||
|
||||
if filename:
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
p1 = os.path.join(root, subfolder, filename)
|
||||
if os.path.isfile(p1) and is_path_safe(root, p1):
|
||||
return p1, root
|
||||
p2 = os.path.join(root, filename)
|
||||
if os.path.isfile(p2) and is_path_safe(root, p2):
|
||||
return p2, root
|
||||
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
d1 = os.path.join(root, subfolder)
|
||||
if os.path.isdir(d1) and is_path_safe(root, d1):
|
||||
return d1, root
|
||||
if os.path.isdir(root):
|
||||
return root, root
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def open_file_in_os(target_path):
|
||||
"""Fast, native cross-platform explorer launch."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
norm_path = os.path.normpath(target_path)
|
||||
if not os.path.exists(norm_path):
|
||||
raise FileNotFoundError(f"Path not found: {norm_path}")
|
||||
if os.path.isfile(norm_path):
|
||||
# Passed as a single argument so Windows Explorer highlights the file properly
|
||||
subprocess.Popen(["explorer", f"/select,{norm_path}"])
|
||||
else:
|
||||
subprocess.Popen(["explorer", norm_path])
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
if not os.path.exists(target_path):
|
||||
raise FileNotFoundError(f"Path not found: {target_path}")
|
||||
if os.path.isfile(target_path):
|
||||
subprocess.Popen(["open", "-R", target_path])
|
||||
else:
|
||||
subprocess.Popen(["open", target_path])
|
||||
|
||||
else: # Linux / Unix
|
||||
folder = os.path.dirname(target_path) if os.path.isfile(target_path) else target_path
|
||||
if not os.path.exists(folder):
|
||||
raise FileNotFoundError(f"Path not found: {folder}")
|
||||
if "DISPLAY" in os.environ or "WAYLAND_DISPLAY" in os.environ:
|
||||
subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
|
||||
async def open_folder_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = (data.get("filename") or "").strip()
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
target_path, _ = resolve_existing_path(filename, subfolder, folder_type)
|
||||
if not target_path:
|
||||
target_path = folder_paths.get_output_directory()
|
||||
|
||||
open_file_in_os(target_path)
|
||||
return web.json_response({"success": True, "path": target_path})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/delete-file")
|
||||
async def delete_file_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = (data.get("filename") or "").strip()
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
if not filename:
|
||||
return web.json_response({"error": "No filename provided"}, status=400)
|
||||
|
||||
# Strictly restrict deletion to generated output and temp files (protects models/checkpoints)
|
||||
delete_roots = [
|
||||
folder_paths.get_output_directory(),
|
||||
folder_paths.get_temp_directory()
|
||||
]
|
||||
delete_roots = [os.path.realpath(r) for r in delete_roots if r and os.path.exists(r)]
|
||||
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type, candidate_roots=delete_roots)
|
||||
|
||||
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
|
||||
return web.json_response({"error": "File not found or outside output directory"}, status=404)
|
||||
|
||||
# Try moving to Recycle Bin / Trash first, fallback to os.remove
|
||||
permanent = bool(data.get("permanent", False))
|
||||
action = "deleted"
|
||||
if not permanent:
|
||||
try:
|
||||
import send2trash
|
||||
send2trash.send2trash(target_file)
|
||||
action = "trashed"
|
||||
except Exception:
|
||||
os.remove(target_file)
|
||||
action = "deleted"
|
||||
else:
|
||||
os.remove(target_file)
|
||||
action = "deleted"
|
||||
|
||||
return web.json_response({"success": True, "deleted": target_file, "action": action})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/promote-output")
|
||||
async def promote_output_handler(request):
|
||||
try:
|
||||
import shutil
|
||||
data = await request.json()
|
||||
raw_filename = (data.get("filename") or "").strip()
|
||||
filename = os.path.basename(raw_filename)
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
target_file, root = resolve_existing_path(raw_filename, subfolder, folder_type)
|
||||
if not target_file or not os.path.isfile(target_file):
|
||||
return web.json_response({"error": "File not found"}, status=404)
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
dest_path = os.path.join(input_dir, filename)
|
||||
if not is_path_safe(input_dir, dest_path):
|
||||
return web.json_response({"error": "Unsafe destination path"}, status=403)
|
||||
|
||||
shutil.copy2(target_file, dest_path)
|
||||
return web.json_response({"name": filename})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,211 @@
|
||||
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 byTestId = document.querySelector('[data-testid="classic-comfy-sidebar-tab-button"]');
|
||||
if (byTestId) return byTestId;
|
||||
|
||||
const icon = document.querySelector('.pi-images');
|
||||
return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null;
|
||||
}
|
||||
|
||||
export function findStandardQueueButton() {
|
||||
// 1. Try modern ComfyUI stable test-ids first
|
||||
const byTestId = document.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"]');
|
||||
if (byTestId && !byTestId.id?.includes('classic-comfy-sidebar') && !byTestId.querySelector('.pi-images')) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
// 2. Fallback to icon discovery
|
||||
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h", '[class*="lucide--history"]']) {
|
||||
const icon = document.querySelector(iconSelector);
|
||||
if (icon) {
|
||||
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
|
||||
if (btn && !btn.querySelector('.pi-images')) return btn;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to semantic labels
|
||||
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
|
||||
for (const btn of buttons) {
|
||||
const title = btn.title || btn.getAttribute('aria-label') || '';
|
||||
if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) &&
|
||||
!btn.querySelector('.pi-images') &&
|
||||
!btn.id?.includes('classic-comfy-sidebar')) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateSidebarBadge(count) {
|
||||
const btn = findOurSidebarButton();
|
||||
if (btn) {
|
||||
let badge = btn.querySelector('.comfy-sidebar-badge');
|
||||
if (count > 0) {
|
||||
if (!badge) {
|
||||
btn.style.position = 'relative';
|
||||
badge = document.createElement('div');
|
||||
badge.className = 'comfy-sidebar-badge';
|
||||
Object.assign(badge.style, {
|
||||
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
|
||||
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
|
||||
});
|
||||
btn.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applySidebarOverride() {
|
||||
const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const stdBtn = findStandardQueueButton();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
|
||||
if (stdBtn) {
|
||||
if (!stdBtn._originalDisplay) {
|
||||
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
|
||||
}
|
||||
if (overrideStock) {
|
||||
stdBtn.style.setProperty("display", "none", "important");
|
||||
if (!stdBtn._overrideClickListener) {
|
||||
stdBtn._overrideClickListener = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const b = findOurSidebarButton();
|
||||
if (b) b.click();
|
||||
};
|
||||
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
|
||||
}
|
||||
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) {
|
||||
stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
|
||||
}
|
||||
} else {
|
||||
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
|
||||
if (stdBtn._overrideClickListener) {
|
||||
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
|
||||
stdBtn._overrideClickListener = null;
|
||||
}
|
||||
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
|
||||
stdBtn.parentNode.appendChild(ourBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function centerAndSelectCanvasNode(nodeId) {
|
||||
if (!app.graph || !app.canvas) return false;
|
||||
const id = typeof nodeId === "string" ? Number(nodeId) : nodeId;
|
||||
const node = app.graph.getNodeById ? (app.graph.getNodeById(id) || app.graph.getNodeById(String(nodeId))) : null;
|
||||
if (node) {
|
||||
if (app.canvas.centerOnNode) app.canvas.centerOnNode(node);
|
||||
if (app.canvas.selectNode) app.canvas.selectNode(node);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import {
|
||||
findOriginalPropertiesButton,
|
||||
findTopbarContainer,
|
||||
isPropertiesPanelOpen,
|
||||
findActiveQueueIndicator,
|
||||
findNativeExtensionsPanel,
|
||||
findGraphButton
|
||||
} from "./adapter.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
.actionbar-container,
|
||||
.shadow-interface.rounded-lg.bg-comfy-menu-bg,
|
||||
.shadow-interface.rounded-lg.border-interface-stroke,
|
||||
.shadow-interface.border.rounded-lg:has([class*="actionbar-buttons"]),
|
||||
.shadow-interface.border.rounded-lg:has(.actionbar-buttons),
|
||||
div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[class*="actionbar"]:not(.actionbar),
|
||||
[class*="actionbar-buttons"],
|
||||
.actionbar-buttons {
|
||||
position: fixed !important;
|
||||
top: 3px !important;
|
||||
right: 4px !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
z-index: 1010 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-extensions-override {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
height: auto !important;
|
||||
position: static !important;
|
||||
order: -10 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
zoom: 0.8 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
img[alt*="User Avatar"],
|
||||
img[alt*="user avatar"],
|
||||
button:has(img[alt*="User Avatar"]),
|
||||
[role="button"]:has(img[alt*="User Avatar"]),
|
||||
button:has(img[alt*="user avatar"]),
|
||||
[role="button"]:has(img[alt*="user avatar"]),
|
||||
.p-avatar,
|
||||
[data-pc-name="avatar"],
|
||||
button:has(.p-avatar),
|
||||
[role="button"]:has(.p-avatar),
|
||||
button:has([data-pc-name="avatar"]),
|
||||
[role="button"]:has([data-pc-name="avatar"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 150px !important;
|
||||
padding-right: 580px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1599px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 100px !important;
|
||||
padding-right: 420px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 40px !important;
|
||||
padding-right: 280px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 180px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
|
||||
if (app.extensionManager?.setting) {
|
||||
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
|
||||
} else if (app.ui?.settings) {
|
||||
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
|
||||
}
|
||||
|
||||
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
|
||||
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
|
||||
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
|
||||
}
|
||||
|
||||
export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
}
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Use stable ComfyUI data-testid selectors first, with icon classes as safe fallback
|
||||
const tabSelectors = {
|
||||
"Assets": '[data-testid="assets-tab-button"], [class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[data-testid="node-library-tab-button"], [class*="comfy--node"]',
|
||||
"Models": '[data-testid="model-library-tab-button"], [class*="comfy--ai-model"]',
|
||||
"Workflows": '[data-testid="workflows-tab-button"], [class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[data-testid="apps-tab-button"], [class*="lucide--panels-top-left"]',
|
||||
"Templates": '[data-testid="templates-tab-button"], [class*="comfy--template"]'
|
||||
};
|
||||
|
||||
Object.entries(tabSelectors).forEach(([tab, selector]) => {
|
||||
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
|
||||
const target = sidebar.querySelector(selector);
|
||||
if (target) {
|
||||
const tabBtn = target.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || target;
|
||||
if (shouldHide) {
|
||||
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (tabBtn.style.display === "none") tabBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const historyTarget = sidebar.querySelector('[data-testid="queue-tab-button"], [data-testid="job-history-tab-button"], [class*="lucide--history"]');
|
||||
if (historyTarget) {
|
||||
const historyBtn = historyTarget.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyTarget;
|
||||
if (hideStockHistory) {
|
||||
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (historyBtn.style.display === "none") historyBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
const stockDot = document.querySelector('.comfy-sidebar-hide-original-properties-btn ~ span, .comfy-sidebar-hide-original-properties-btn + span');
|
||||
let customDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
|
||||
if (stockDot) {
|
||||
if (!customDot) {
|
||||
customDot = document.createElement("span");
|
||||
customDot.className = "comfy-sidebar-error-dot inline-flex items-center justify-center rounded-full bg-destructive-background text-white size-2 absolute -top-1 -right-1";
|
||||
customBtn.appendChild(customDot);
|
||||
}
|
||||
} else if (customDot) {
|
||||
customDot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHTML(html) {
|
||||
if (!html) return "";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
const tempDot = tempDiv.querySelector('[class*="destructive"]');
|
||||
if (tempDot) tempDot.remove();
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
function syncGraphButton() {
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncExtensionsPanel(isClassicLayoutEnabled) {
|
||||
const extensionsPanel = findNativeExtensionsPanel();
|
||||
if (!extensionsPanel) return;
|
||||
|
||||
if (isClassicLayoutEnabled) {
|
||||
const container = findTopbarContainer();
|
||||
if (container && extensionsPanel.parentNode !== container) {
|
||||
if (!extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent = extensionsPanel.parentNode;
|
||||
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
|
||||
}
|
||||
container.appendChild(extensionsPanel);
|
||||
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
|
||||
}
|
||||
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
|
||||
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
|
||||
}
|
||||
}
|
||||
|
||||
function syncPropertiesButton(isClassicLayoutEnabled) {
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) customBtn.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalPropertiesButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
if (customBtn) {
|
||||
if (originalBtn) {
|
||||
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
|
||||
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
|
||||
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
customBtn.innerHTML = cleanedHTML;
|
||||
if (existingDot) customBtn.appendChild(existingDot);
|
||||
}
|
||||
|
||||
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
|
||||
customBtn.setAttribute("title", titleVal);
|
||||
customBtn.setAttribute("aria-label", titleVal);
|
||||
|
||||
for (const cls of originalBtn.classList) {
|
||||
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
|
||||
customBtn.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
syncErrorBadge(originalBtn, customBtn);
|
||||
} else if (!customBtn.getAttribute("title")) {
|
||||
customBtn.setAttribute("title", "Toggle properties panel");
|
||||
customBtn.setAttribute("aria-label", "Toggle properties panel");
|
||||
}
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveQueueIndicator(isClassicLayoutEnabled) {
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator) {
|
||||
if (isClassicLayoutEnabled && hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
|
||||
} else if (indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncClassicLayout() {
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
try {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
|
||||
syncGraphButton();
|
||||
syncExtensionsPanel(isClassicLayoutEnabled);
|
||||
syncPropertiesButton(isClassicLayoutEnabled);
|
||||
syncActiveQueueIndicator(isClassicLayoutEnabled);
|
||||
updateSidebarTabsVisibility();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
|
||||
} finally {
|
||||
if (domObserver) {
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPropertiesPanelToggleFix() {
|
||||
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-layout-fix-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-hide-original-properties-btn ~ span,
|
||||
.comfy-sidebar-hide-original-properties-btn + span {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle {
|
||||
order: 99999 !important;
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
margin-left: -8px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldSync = false;
|
||||
|
||||
const isInternalSidebarMutation = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
return node.closest?.('.comfyui-sidebar, .comfy-sidebar, .comfy-sidebar-comparison-overlay') ||
|
||||
node.classList?.contains('comfy-sidebar-card');
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
|
||||
const hasExternalElementMutation =
|
||||
Array.from(mutation.addedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node)) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node));
|
||||
|
||||
if (hasExternalElementMutation) {
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldSync && !syncScheduled) {
|
||||
syncScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
syncClassicLayout();
|
||||
syncScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
syncClassicLayout();
|
||||
}
|
||||
|
||||
export function destroyLayoutFix() {
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = null;
|
||||
}
|
||||
const styleEl = document.getElementById(STYLE_ID);
|
||||
if (styleEl) styleEl.remove();
|
||||
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
|
||||
if (fixStyles) fixStyles.remove();
|
||||
}
|
||||
@@ -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,407 +0,0 @@
|
||||
import { isVideoFormat, is3DFormat } from "./utils.js";
|
||||
import { app } from "/scripts/app.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 () => {};
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
let wasDragging = false;
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
if (wasDragging) {
|
||||
wasDragging = false;
|
||||
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;
|
||||
|
||||
const src = imgSrcs[0];
|
||||
|
||||
// Handle 3D assets natively or by focusing 3D node
|
||||
if (is3DFormat(src)) {
|
||||
if (window.app?.ui?.show3DViewer) {
|
||||
window.app.ui.show3DViewer(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.graph && app.canvas) {
|
||||
const nodes = app.graph._nodes || [];
|
||||
const node = nodes.find(n =>
|
||||
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
|
||||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
|
||||
);
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(src);
|
||||
}
|
||||
@@ -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
-136
@@ -1,136 +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_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;
|
||||
}
|
||||
}
|
||||
|
||||
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 workflow JSON drop (text-only and unfinished cards)
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
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 image / video / 3D 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 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);
|
||||
}
|
||||
|
||||
// Check if dropped on a LoadImage, LoadVideo, Load3D, VHS_LoadVideo, or image/video/3D widget node
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || 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("Preview3D")
|
||||
);
|
||||
|
||||
if (isLoadNode) {
|
||||
const targetWidget = widget || targetNode.widgets?.[0];
|
||||
if (targetWidget) {
|
||||
const newFilename = await uploadDroppedImageToInput({ 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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas area: fetch file and load embedded workflow metadata
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
-559
@@ -1,559 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
/* Remove the ghost underlay container border & background (the oval) without affecting the topbar header */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
|
||||
[class*="actionbar"]:not(.actionbar),
|
||||
[class*="actionbar-buttons"],
|
||||
.actionbar-buttons {
|
||||
position: fixed !important;
|
||||
top: 3px !important;
|
||||
right: 4px !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
z-index: 1010 !important;
|
||||
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Style overrides for the new native Extensions button container when embedded in top actionbar */
|
||||
.comfy-sidebar-extensions-override {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
height: auto !important;
|
||||
position: static !important;
|
||||
order: -10 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Scale down performance indicators (Crystools) uniformly */
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
zoom: 0.8 !important;
|
||||
display: inline-flex !important;
|
||||
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"]),
|
||||
[role="button"]:has(img[alt*="User Avatar"]),
|
||||
button:has(img[alt*="user avatar"]),
|
||||
[role="button"]:has(img[alt*="user avatar"]),
|
||||
.p-avatar,
|
||||
[data-pc-name="avatar"],
|
||||
button:has(.p-avatar),
|
||||
[role="button"]:has(.p-avatar),
|
||||
button:has([data-pc-name="avatar"]),
|
||||
[role="button"]:has([data-pc-name="avatar"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 150px !important;
|
||||
padding-right: 580px !important;
|
||||
}
|
||||
|
||||
/* Medium desktop screens (between 1200px and 1599px width) */
|
||||
@media (max-width: 1599px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 100px !important;
|
||||
padding-right: 420px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 40px !important;
|
||||
padding-right: 280px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 180px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Native settings synchronization without direct DOM element querying or simulated clicks.
|
||||
*/
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
|
||||
// Use official extensionManager setting API if present, with fallback to app.ui.settings
|
||||
if (app.extensionManager?.setting) {
|
||||
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
|
||||
} else if (app.ui?.settings) {
|
||||
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
|
||||
}
|
||||
|
||||
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
|
||||
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
|
||||
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
|
||||
}
|
||||
|
||||
export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
}
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
|
||||
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const origBtn = findOriginalButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
|
||||
const tabIconSelectors = {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
|
||||
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
|
||||
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
|
||||
const icon = sidebar.querySelector(selector);
|
||||
if (icon) {
|
||||
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
|
||||
if (shouldHide) {
|
||||
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (tabBtn.style.display === "none") tabBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
|
||||
if (historyIcon) {
|
||||
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
|
||||
if (hideStockHistory) {
|
||||
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (historyBtn.style.display === "none") historyBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
|
||||
for (const el of elements) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
return el.closest('.group, .p-1') || el;
|
||||
}
|
||||
|
||||
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
|
||||
for (const el of fallbacks) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
|
||||
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
const stockDot = document.querySelector('.comfy-sidebar-hide-original-properties-btn ~ span, .comfy-sidebar-hide-original-properties-btn + span');
|
||||
let customDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
|
||||
if (stockDot) {
|
||||
if (!customDot) {
|
||||
customDot = document.createElement("span");
|
||||
customDot.className = "comfy-sidebar-error-dot inline-flex items-center justify-center rounded-full bg-destructive-background text-white size-2 absolute -top-1 -right-1";
|
||||
customBtn.appendChild(customDot);
|
||||
}
|
||||
} else if (customDot) {
|
||||
customDot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHTML(html) {
|
||||
if (!html) return "";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
const tempDot = tempDiv.querySelector('[class*="destructive"]');
|
||||
if (tempDot) tempDot.remove();
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
export function syncClassicLayout() {
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
try {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
|
||||
const extensionsPanel = findNativeExtensionsPanel();
|
||||
if (extensionsPanel) {
|
||||
if (isClassicLayoutEnabled) {
|
||||
const container = findTopbarContainer();
|
||||
if (container && extensionsPanel.parentNode !== container) {
|
||||
if (!extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent = extensionsPanel.parentNode;
|
||||
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
|
||||
}
|
||||
container.appendChild(extensionsPanel);
|
||||
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
|
||||
}
|
||||
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
|
||||
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) customBtn.remove();
|
||||
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator && indicator.style.display === "none") indicator.style.removeProperty("display");
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
if (customBtn) {
|
||||
if (originalBtn) {
|
||||
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
|
||||
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
|
||||
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
customBtn.innerHTML = cleanedHTML;
|
||||
if (existingDot) customBtn.appendChild(existingDot);
|
||||
}
|
||||
|
||||
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
|
||||
customBtn.setAttribute("title", titleVal);
|
||||
customBtn.setAttribute("aria-label", titleVal);
|
||||
|
||||
for (const cls of originalBtn.classList) {
|
||||
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
|
||||
customBtn.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
syncErrorBadge(originalBtn, customBtn);
|
||||
} else if (!customBtn.getAttribute("title")) {
|
||||
customBtn.setAttribute("title", "Toggle properties panel");
|
||||
customBtn.setAttribute("aria-label", "Toggle properties panel");
|
||||
}
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator) {
|
||||
if (hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
|
||||
} else if (indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
|
||||
} finally {
|
||||
if (domObserver) {
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPropertiesPanelToggleFix() {
|
||||
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-layout-fix-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-hide-original-properties-btn ~ span,
|
||||
.comfy-sidebar-hide-original-properties-btn + span {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle {
|
||||
order: 99999 !important;
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
margin-left: -8px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldSync = false;
|
||||
|
||||
const isInternalSidebarMutation = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
return node.closest?.('.comfyui-sidebar, .comfy-sidebar, .comfy-sidebar-comparison-overlay') ||
|
||||
node.classList?.contains('comfy-sidebar-card');
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
|
||||
const hasExternalElementMutation =
|
||||
Array.from(mutation.addedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node)) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node));
|
||||
|
||||
if (hasExternalElementMutation) {
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldSync && !syncScheduled) {
|
||||
syncScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
syncClassicLayout();
|
||||
syncScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
syncClassicLayout();
|
||||
}
|
||||
-295
@@ -1,295 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js";
|
||||
|
||||
export let renderDOMFn = () => {};
|
||||
export let updateSidebarBadgeFn = () => {};
|
||||
export function setUIDependencies(renderFn, badgeFn) {
|
||||
renderDOMFn = renderFn;
|
||||
updateSidebarBadgeFn = badgeFn;
|
||||
}
|
||||
|
||||
export async function syncQueue() {
|
||||
try {
|
||||
const q = await api.getQueue();
|
||||
|
||||
const runningList = q.Running || q.queue_running || [];
|
||||
const pendingList = q.Pending || q.queue_pending || [];
|
||||
const pendingIds = new Set();
|
||||
|
||||
const normalizedPending = pendingList.map((p, idx) => {
|
||||
let pid = null, seq = idx;
|
||||
if (Array.isArray(p)) {
|
||||
seq = typeof p[0] === 'number' ? p[0] : idx;
|
||||
pid = p[1];
|
||||
} else if (p && typeof p === "object") {
|
||||
pid = p.prompt_id || p.id || p.uuid;
|
||||
seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx);
|
||||
}
|
||||
return { pid: pid ? String(pid) : null, seq, original: p };
|
||||
});
|
||||
|
||||
normalizedPending.sort((a, b) => a.seq - b.seq);
|
||||
normalizedPending.forEach((item, index) => {
|
||||
const pid = item.pid;
|
||||
const number = index + 1;
|
||||
|
||||
if (pid) {
|
||||
pendingIds.add(pid);
|
||||
if (!promptStates.has(pid)) {
|
||||
State.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) => {
|
||||
const key = String(pid);
|
||||
if (!key || !promptStates.has(key)) return;
|
||||
if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null;
|
||||
|
||||
const st = promptStates.get(key);
|
||||
|
||||
if (st._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
delete st._previewBlobUrl;
|
||||
}
|
||||
if (st._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._oldPreviewBlobUrl); } catch(e){}
|
||||
delete st._oldPreviewBlobUrl;
|
||||
}
|
||||
|
||||
st.status = statusStr;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
st.endTime = Date.now();
|
||||
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[key]) {
|
||||
const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null;
|
||||
st.workflow = parseWorkflow(rawWf) || st.workflow;
|
||||
st.nodeOutputs = hItem[key].outputs;
|
||||
|
||||
const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow);
|
||||
if (primaryImgs.length > 0) st.images = primaryImgs;
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow);
|
||||
if (fetchedTexts.length > 0) st.texts = fetchedTexts;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
pruneHistory(app);
|
||||
saveStatesToLocalStorage();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("reconnected", async () => {
|
||||
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
|
||||
await initSessionAndHistory();
|
||||
await 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(()=>{});
|
||||
saveStatesToLocalStorage();
|
||||
}
|
||||
}
|
||||
|
||||
const pid = String(e.detail.prompt_id);
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.status = "active"; st.progressText = "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 ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
|
||||
const cardObj = cardElements.get(pid);
|
||||
if (cardObj && cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${st.progress}%`;
|
||||
if (cardObj.statusText && cardObj.statusText.style.display !== "none") {
|
||||
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Sampling...";
|
||||
cardObj.statusText.textContent = `${nodeName} ${st.progress}%`;
|
||||
}
|
||||
} else {
|
||||
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];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
st._oldPreviewBlobUrl = st._previewBlobUrl;
|
||||
st._previewBlobUrl = newBlobUrl;
|
||||
st.images = [{ url: newBlobUrl }];
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("executed", (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeImgs.length > 0) {
|
||||
st.images = nodeImgs;
|
||||
}
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
st.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
st.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
|
||||
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() {
|
||||
try {
|
||||
const historyData = await api.getHistory();
|
||||
if (!historyData) return;
|
||||
|
||||
const rawItems = [];
|
||||
if (Array.isArray(historyData.History)) {
|
||||
historyData.History.forEach(item => {
|
||||
if (item && item.prompt_id) rawItems.push({ pid: String(item.prompt_id), data: item });
|
||||
});
|
||||
} else if (typeof historyData === 'object') {
|
||||
Object.keys(historyData).forEach(pidKey => {
|
||||
const item = historyData[pidKey];
|
||||
rawItems.push({ pid: String(pidKey), data: Array.isArray(item) ? { prompt: item[0], outputs: item[1], status: item[2] } : item });
|
||||
});
|
||||
}
|
||||
|
||||
rawItems.sort((a, b) => {
|
||||
const numA = a.data.prompt?.[0] ?? 0;
|
||||
const numB = b.data.prompt?.[0] ?? 0;
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
rawItems.forEach(({ pid, data }) => {
|
||||
if (promptStates.has(pid)) return;
|
||||
|
||||
const extraData = data.extra_data || data.prompt?.[3] || {};
|
||||
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
const outputs = data.outputs || {};
|
||||
|
||||
const images = getPrimaryOutputImages(outputs, workflow);
|
||||
const texts = findTextsInOutputs(outputs, workflow);
|
||||
|
||||
const statusStr = data.status?.status_str || "completed";
|
||||
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
|
||||
(statusStr === "error" ? "error" : "cancelled");
|
||||
|
||||
// Filter out empty records from history so dummy "No Outputs" cards don't pollute queue
|
||||
if (images.length === 0 && texts.length === 0 && status === "completed") return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
status: status,
|
||||
images: images,
|
||||
texts: texts,
|
||||
nodeOutputs: outputs,
|
||||
workflow: workflow,
|
||||
progressText: "",
|
||||
timestamp: State.globalOrderCounter,
|
||||
rendered: true
|
||||
});
|
||||
});
|
||||
|
||||
pruneHistory(app);
|
||||
saveStatesToLocalStorage();
|
||||
await syncQueue();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
|
||||
}
|
||||
}
|
||||
@@ -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 || '')}&rand=${encodeURIComponent(pid)}`);
|
||||
updateActiveComparisonPreview(pid, finalSrc);
|
||||
}
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
prompt.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!prompt.nodeOutputs) prompt.nodeOutputs = {};
|
||||
prompt.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
store.updatePrompt(pid, prompt);
|
||||
}
|
||||
};
|
||||
|
||||
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, PromptStatus.COMPLETED);
|
||||
const onExecutionError = (e) => {
|
||||
const pid = e.detail?.prompt_id;
|
||||
const nodeId = e.detail?.node_id;
|
||||
if (pid && nodeId && store.hasPrompt(String(pid))) {
|
||||
const prompt = store.getPrompt(String(pid));
|
||||
prompt.activeNodeId = String(nodeId);
|
||||
store.updatePrompt(String(pid), prompt);
|
||||
}
|
||||
concludeRun(e.detail.prompt_id, PromptStatus.ERROR);
|
||||
};
|
||||
const onExecutionInterrupted = () => {
|
||||
store.getAllPrompts()
|
||||
.filter(t => t.status === PromptStatus.ACTIVE)
|
||||
.forEach(t => concludeRun(t.pid, PromptStatus.CANCELLED));
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
api.addEventListener("status", onStatus);
|
||||
api.addEventListener("reconnected", onReconnected);
|
||||
api.addEventListener("execution_start", onExecutionStart);
|
||||
api.addEventListener("progress", onProgress);
|
||||
api.addEventListener("executing", onExecuting);
|
||||
api.addEventListener("b_preview", onBPreview);
|
||||
api.addEventListener("executed", onExecuted);
|
||||
api.addEventListener("execution_success", onExecutionSuccess);
|
||||
api.addEventListener("execution_error", onExecutionError);
|
||||
api.addEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
|
||||
return () => {
|
||||
api.removeEventListener("status", onStatus);
|
||||
api.removeEventListener("reconnected", onReconnected);
|
||||
api.removeEventListener("execution_start", onExecutionStart);
|
||||
api.removeEventListener("progress", onProgress);
|
||||
api.removeEventListener("executing", onExecuting);
|
||||
api.removeEventListener("b_preview", onBPreview);
|
||||
api.removeEventListener("executed", onExecuted);
|
||||
api.removeEventListener("execution_success", onExecutionSuccess);
|
||||
api.removeEventListener("execution_error", onExecutionError);
|
||||
api.removeEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+268
-351
@@ -1,351 +1,268 @@
|
||||
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;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
|
||||
// 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) {
|
||||
// Robust multi-selector search for Vue node containers across different frontend versions
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
let nodes = [];
|
||||
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
|
||||
nodes = Object.values(canvas.selected_nodes);
|
||||
} else if (canvas.current_active_node) {
|
||||
nodes = [canvas.current_active_node];
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) 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 });
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui && app.ui.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode()
|
||||
}
|
||||
],
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncNodeVueBadge(node, true);
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => syncAllNodeBadges());
|
||||
},
|
||||
|
||||
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();
|
||||
|
||||
// Watch for Vue DOM node mounting events (e.g. LiteGraph ↔ Vue mode switches)
|
||||
setupVueNodeObserver();
|
||||
|
||||
// Remove any existing keydown listener from previous hot-reload
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
// Clean, direct keyboard listener
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
// Toggle sidebar with Q
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
// Toggle ignore node with Ctrl+Q or Cmd+Q (Mac)
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
// Apply sidebar override when initializing
|
||||
applySidebarOverride();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
title: "Queue",
|
||||
tooltip: "Comfy Queue (Q)",
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (nodeDOMObserver) {
|
||||
nodeDOMObserver.disconnect();
|
||||
nodeDOMObserver = null;
|
||||
}
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (app.extensionManager?.unregisterSidebarTab) {
|
||||
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
|
||||
}
|
||||
}
|
||||
});
|
||||
import { app } from "/scripts/app.js";
|
||||
import { SettingIds } from "./core/constants.js";
|
||||
import { injectStyles } from "./utils/styles.js";
|
||||
import { setupDragAndDrop } from "./utils/dragdrop.js";
|
||||
import { renumberNodesTopologically, setManualNodeId } from "./utils/nodeRenumber.js";
|
||||
import { findOurSidebarButton } from "./comfy/adapter.js";
|
||||
import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js";
|
||||
import { initSessionAndHistory, syncQueue } from "./queue/queueService.js";
|
||||
import { setupExecutionTracker } from "./queue/executionTracker.js";
|
||||
import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js";
|
||||
import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js";
|
||||
import { store } from "./core/store.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let cleanupFns = [];
|
||||
|
||||
function registerSettings() {
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.GRID_COLUMNS_THRESHOLD,
|
||||
name: "Width Threshold for Queue Columns (px)",
|
||||
type: "number",
|
||||
defaultValue: 350
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.SHOW_PENDING_COUNT_ONLY,
|
||||
name: "If disabled, each queued job will have a separate individual card",
|
||||
type: "boolean",
|
||||
defaultValue: true
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.SHOW_WORKING_NODE_NAME,
|
||||
name: "Shows the name of the node which is currently in the process",
|
||||
type: "boolean",
|
||||
defaultValue: true
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.AUTO_CLEAR_INTERRUPTED,
|
||||
name: "Auto-clear cancelled & failed jobs on new generation",
|
||||
type: "boolean",
|
||||
defaultValue: false
|
||||
});
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Permanent Delete on Disk Deletion",
|
||||
name: "Permanently delete files on disk deletion (bypasses Recycle Bin / Trash)",
|
||||
type: "boolean",
|
||||
defaultValue: false
|
||||
});
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.OVERRIDE_STOCK_HISTORY,
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui?.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.HIDE_GRAPH_BUTTON,
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.CLASSIC_LAYOUT,
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupKeydownShortcuts() {
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode(() => renderSidebar());
|
||||
}
|
||||
|
||||
// Alt+R: Renumber all nodes in execution order
|
||||
if (e.key.toLowerCase() === "r" && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
renumberNodesTopologically();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
registerSettings();
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode(() => renderSidebar())
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.RenumberNodes",
|
||||
label: "Renumber All Nodes (Execution Order)",
|
||||
function: () => renumberNodesTopologically()
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.SetNodeId",
|
||||
label: "Set Selected Node ID...",
|
||||
function: () => setManualNodeId()
|
||||
}
|
||||
],
|
||||
|
||||
getNodeMenuItems(node) {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
content: "Set Node ID...",
|
||||
callback: () => setManualNodeId(node)
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
getCanvasMenuItems() {
|
||||
return [
|
||||
null,
|
||||
{
|
||||
content: "Renumber All Nodes (Execution Order)",
|
||||
callback: () => renumberNodesTopologically()
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncAllNodeBadges();
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => {
|
||||
syncAllNodeBadges();
|
||||
renderSidebar();
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
setupPropertiesPanelToggleFix();
|
||||
injectStyles();
|
||||
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupVueNodeObserver());
|
||||
|
||||
// Setup execution tracker with targeted fast-path update for active card
|
||||
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
|
||||
const cardObj = cardPool.get(pid);
|
||||
const state = store.getPrompt(pid);
|
||||
if (cardObj && state) {
|
||||
const showWorkingNode = app.ui.settings.getSettingValue(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
|
||||
updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode, state);
|
||||
} else {
|
||||
renderSidebar();
|
||||
}
|
||||
}));
|
||||
|
||||
const sidebarContainer = setupSidebarView();
|
||||
|
||||
await initSessionAndHistory();
|
||||
setupKeydownShortcuts();
|
||||
|
||||
syncAllNodeBadges();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
title: "Queue",
|
||||
tooltip: "Comfy Queue (Q)",
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
for (const fn of cleanupFns.splice(0)) {
|
||||
try { if (typeof fn === "function") fn(); } catch (e) {}
|
||||
}
|
||||
destroyLayoutFix();
|
||||
teardownSidebarView();
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (app.extensionManager?.unregisterSidebarTab) {
|
||||
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
export const promptStates = new Map();
|
||||
export const cardElements = new Map();
|
||||
|
||||
export const State = {
|
||||
currentSearchQuery: "",
|
||||
globalOrderCounter: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null, // Main queue scroll container
|
||||
submenuStack: null, // Submenu scroll container
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
|
||||
activeSubmenuBatchImages: null // Active batch images explorer pointer
|
||||
};
|
||||
|
||||
export function deletePromptState(pid) {
|
||||
const key = String(pid);
|
||||
const state = promptStates.get(key);
|
||||
if (state) {
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
}
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
}
|
||||
}
|
||||
promptStates.delete(key);
|
||||
cardElements.delete(key);
|
||||
}
|
||||
|
||||
export function pruneHistory(app) {
|
||||
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
const tasks = Array.from(promptStates.entries())
|
||||
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
|
||||
|
||||
tasks.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
|
||||
if (tasks.length > maxItems) {
|
||||
const deleteCount = tasks.length - maxItems;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
const [pid] = tasks[i];
|
||||
deletePromptState(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStatesToLocalStorage() {
|
||||
try {
|
||||
let serializable = [];
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
const cleanedImages = (state.images || []).map(img => {
|
||||
if (img.url && img.url.startsWith("blob:")) return null;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Include workflow JSON ONLY for image-less / unfinished / failed tasks
|
||||
// so users can load or save their workflows even after browser reloads!
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
|
||||
|
||||
serializable.push({
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "",
|
||||
rendered: state.rendered || false,
|
||||
startTime: state.startTime,
|
||||
endTime: state.endTime,
|
||||
duration: state.duration
|
||||
});
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
try {
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function loadStatesFromLocalStorage() {
|
||||
try {
|
||||
const data = localStorage.getItem("comfy_sidebar_prompt_states");
|
||||
if (data) {
|
||||
const list = JSON.parse(data);
|
||||
list.forEach(state => {
|
||||
const key = String(state.pid);
|
||||
promptStates.set(key, state);
|
||||
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadStatesFromLocalStorage();
|
||||
-254
@@ -1,254 +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;
|
||||
}
|
||||
|
||||
/* Hover Panels CSS Hover Control */
|
||||
.comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card:hover .comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-card: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: 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;
|
||||
}
|
||||
|
||||
/* Header Icons & Header Action Buttons */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Scroll To Top Floating Button */
|
||||
.comfy-sidebar-scroll-top-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isAudioViewerOpen as checkAudioViewerOpen } from "../utils/comparison.js";
|
||||
|
||||
let currentlyPlayingAudio = null;
|
||||
|
||||
export function stopAllAudioPlayback() {
|
||||
if (currentlyPlayingAudio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
currentlyPlayingAudio.currentTime = 0;
|
||||
if (typeof currentlyPlayingAudio._onResetUI === "function") {
|
||||
currentlyPlayingAudio._onResetUI();
|
||||
}
|
||||
currentlyPlayingAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCurrentlyPlayingAudio(audio) {
|
||||
stopAllAudioPlayback();
|
||||
currentlyPlayingAudio = audio;
|
||||
}
|
||||
|
||||
export function getCurrentlyPlayingAudio() {
|
||||
return currentlyPlayingAudio;
|
||||
}
|
||||
|
||||
export function isAudioViewerOpen() {
|
||||
return checkAudioViewerOpen();
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { renderCardImages } from "./mediaPreview.js";
|
||||
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage, removeImageFromNodeOutputs } from "./mediaActions.js";
|
||||
import { centerAndSelectCanvasNode } from "../comfy/adapter.js";
|
||||
import { getRunOutputs, extractWorkflowFromPng, isImageFormat } from "../utils/utils.js";
|
||||
import { showFullscreenPreview } from "../utils/comparison.js";
|
||||
import { stopAllAudioPlayback } from "./audioController.js";
|
||||
import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js";
|
||||
import { showNotification } from "../utils/nodeRenumber.js";
|
||||
|
||||
export const cardPool = new Map();
|
||||
|
||||
export function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardPool.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function syncCardButtonVisibility(cardObj, state) {
|
||||
if (!cardObj) return;
|
||||
|
||||
const isPendingOrActive = state.status === PromptStatus.PENDING || state.status === PromptStatus.ACTIVE;
|
||||
if (isPendingOrActive) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
} else {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
|
||||
}
|
||||
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
|
||||
// Check if the card has an actual verified file saved on disk
|
||||
const hasRealDiskFiles = state.images && state.images.length > 0 &&
|
||||
state.images.some(img => img.filename && !img.isFallback && (!img.url || !img.url.startsWith("blob:")));
|
||||
|
||||
const hasRealImages = hasRealDiskFiles && !state.images.some(img => img.url && img.url.startsWith("blob:"));
|
||||
const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null;
|
||||
const isPng = currentImg && (currentImg.filename || "").toLowerCase().endsWith(".png");
|
||||
|
||||
if (cardObj.btnImg) {
|
||||
if (hasRealImages) {
|
||||
cardObj.btnImg.style.removeProperty("display");
|
||||
cardObj.btnImg.style.display = "inline-flex";
|
||||
cardObj.btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
state.images.forEach(img => {
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
} else {
|
||||
cardObj.btnImg.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnCopy) {
|
||||
const isCopyableImage = hasRealImages && currentImg && isImageFormat(currentImg.filename || currentImg.url);
|
||||
if (isCopyableImage) {
|
||||
cardObj.btnCopy.style.removeProperty("display");
|
||||
cardObj.btnCopy.style.display = "inline-flex";
|
||||
cardObj.btnCopy.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "#4ade80";
|
||||
setTimeout(() => {
|
||||
cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "";
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cardObj.btnCopy.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnJson) {
|
||||
if (state.workflow || isPng) {
|
||||
cardObj.btnJson.style.removeProperty("display");
|
||||
cardObj.btnJson.style.display = "inline-flex";
|
||||
cardObj.btnJson.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
let wf = state.workflow;
|
||||
if (!wf && currentImg) {
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}${randParam}`;
|
||||
wf = await extractWorkflowFromPng(src);
|
||||
if (wf) state.workflow = wf;
|
||||
}
|
||||
if (!wf) {
|
||||
alert("No workflow metadata found in this file.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workflow_${state.pid || "comfy"}.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
};
|
||||
} else {
|
||||
cardObj.btnJson.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnDel) {
|
||||
// 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) {
|
||||
// Check if intermediate files still exist on disk
|
||||
for (const out of distinctOutputs) {
|
||||
for (const img of out.images) {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
if (!src.startsWith("blob:")) {
|
||||
fetch(src, { method: "HEAD" }).then(res => {
|
||||
if (res.status === 404) {
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
if (liveState && liveState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
|
||||
cardObj.leftHoverBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
// On click: verify distinct outputs still exist before opening
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
const primSigs = new Set((liveState.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
|
||||
const curDistinct = curOutputs.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
|
||||
if (curDistinct.length === 0) {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
}
|
||||
|
||||
let anyMissing = false;
|
||||
for (const out of curDistinct) {
|
||||
for (const img of out.images) {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
if (!src.startsWith("blob:")) {
|
||||
try {
|
||||
const res = await fetch(src, { method: "HEAD" });
|
||||
if (res.status === 404) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
anyMissing = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyMissing) {
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
const remaining = getRunOutputs(liveState.nodeOutputs, liveState.workflow)
|
||||
.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
if (remaining.length === 0) {
|
||||
return; // All intermediate outputs were wiped; button is now hidden
|
||||
}
|
||||
}
|
||||
|
||||
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|
||||
|| store.ui.cardStack?.parentElement
|
||||
|| store.ui.cardStack;
|
||||
if (scrollEl) {
|
||||
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
store.openOutputsSubmenu(liveState.pid);
|
||||
};
|
||||
} 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 randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}` : null);
|
||||
if (previewUrl && cardObj.firstImgElement && cardObj.firstImgElement.tagName === "IMG") {
|
||||
const currentSrc = cardObj.firstImgElement.getAttribute("src") || cardObj.firstImgElement.src;
|
||||
if (currentSrc !== previewUrl && !cardObj.firstImgElement.src.endsWith(previewUrl)) {
|
||||
cardObj.firstImgElement.src = previewUrl;
|
||||
}
|
||||
} else {
|
||||
updateCardDOM(cardObj, state, true, showWorkingNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrCreateCard(state, callbacks = {}) {
|
||||
let cardObj = cardPool.get(state.pid);
|
||||
if (cardObj) return cardObj;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.id = `card-${state.pid}`;
|
||||
card.className = `comfy-sidebar-card ${state.status || "completed"}`;
|
||||
card.style.position = "relative";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
|
||||
const cancelX = document.createElement("span");
|
||||
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
|
||||
Object.assign(cancelX.style, {
|
||||
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "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));
|
||||
|
||||
let deletedCount = 0;
|
||||
let lastAction = "trashed";
|
||||
for (const fileItem of filesToDelete) {
|
||||
const action = await deleteFileOnServer(fileItem);
|
||||
if (action) {
|
||||
deletedCount++;
|
||||
lastAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
const msg = lastAction === "trashed"
|
||||
? `Moved ${deletedCount} file${deletedCount > 1 ? "s" : ""} to Recycle Bin.`
|
||||
: `Permanently deleted ${deletedCount} file${deletedCount > 1 ? "s" : ""}.`;
|
||||
showNotification(msg, "success", "Delete Files");
|
||||
}
|
||||
}
|
||||
|
||||
deleteHistoryItem(state.pid);
|
||||
}
|
||||
};
|
||||
|
||||
cardPool.set(state.pid, cardObj);
|
||||
return cardObj;
|
||||
}
|
||||
|
||||
export function updateCardDOM(cardObj, state, showPendingSummary = true, showWorkingNode = true) {
|
||||
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
|
||||
cardObj.element.style.position = "relative";
|
||||
|
||||
const isUnfinished = state.status && state.status !== PromptStatus.COMPLETED;
|
||||
if (state.images && state.images.length > 0 && !isUnfinished) {
|
||||
cardObj.element.removeAttribute("draggable");
|
||||
} else {
|
||||
cardObj.element.setAttribute("draggable", "true");
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.ACTIVE) {
|
||||
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
|
||||
cardObj.timerEl.style.display = "block";
|
||||
} else if (state.duration !== undefined && state.duration !== null) {
|
||||
cardObj.timerEl.textContent = state.duration.toFixed(2) + "s";
|
||||
cardObj.timerEl.style.display = "block";
|
||||
} else {
|
||||
cardObj.timerEl.style.display = "none";
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.CANCELLED) {
|
||||
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#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.title = "Cancel Queued Task";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
cancelPendingTask(state.pid);
|
||||
};
|
||||
} else if (state.status === PromptStatus.ACTIVE) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.title = "Interrupt Execution";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
interruptActive();
|
||||
};
|
||||
} else {
|
||||
cardObj.cancelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
|
||||
if (cardObj.lastImagesSignature !== currentImagesSignature) {
|
||||
if (!state.images || state.images.length === 0) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
cardObj.placeholder.style.display = "block";
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, state, (st) => {
|
||||
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|
||||
|| store.ui.cardStack?.parentElement
|
||||
|| store.ui.cardStack;
|
||||
if (scrollEl) {
|
||||
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
store.openBatchSubmenu({
|
||||
pid: st.pid,
|
||||
images: st.images,
|
||||
workflow: st.workflow,
|
||||
nodeOutputs: st.nodeOutputs
|
||||
});
|
||||
});
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
|
||||
if (state.images.length === 0) {
|
||||
if (state.texts && state.texts.length > 0) {
|
||||
const fullText = state.texts.join("\n\n");
|
||||
cardObj.placeholder.textContent = fullText;
|
||||
cardObj.placeholder.title = "Click to read full text";
|
||||
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
|
||||
cardObj.placeholder.style.cssText = "";
|
||||
cardObj.placeholder.style.display = "-webkit-box";
|
||||
cardObj.placeholder.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
|
||||
};
|
||||
} 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,127 @@
|
||||
export async function copyImageToClipboard(src) {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
let blob = await res.blob();
|
||||
if (blob.type !== "image/png") {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to copy image to clipboard", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFileOrFolder(img) {
|
||||
if (!img) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/comfy-sidebar/open-folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: img.filename,
|
||||
subfolder: img.subfolder || "",
|
||||
type: img.type || "output"
|
||||
})
|
||||
});
|
||||
if (res.ok) return;
|
||||
} catch (e) {}
|
||||
|
||||
const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.target = "_blank";
|
||||
a.download = img.filename || "file";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export async function deleteFileOnServer(fileItem) {
|
||||
if (!fileItem || !fileItem.filename) return null;
|
||||
const isPermanent = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Permanent Delete on Disk Deletion") ?? false;
|
||||
try {
|
||||
const res = await fetch("/comfy-sidebar/delete-file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: fileItem.filename,
|
||||
subfolder: fileItem.subfolder || "",
|
||||
type: fileItem.type || "output",
|
||||
permanent: isPermanent
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.action || (isPermanent ? "deleted" : "trashed");
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to delete file on server", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeImageFromNodeOutputs(nodeOutputs, targetImg) {
|
||||
if (!nodeOutputs || !targetImg || !targetImg.filename) return;
|
||||
|
||||
const scanAndRemove = (obj) => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const key in obj) {
|
||||
const val = obj[key];
|
||||
if (Array.isArray(val)) {
|
||||
for (let i = val.length - 1; i >= 0; i--) {
|
||||
const item = val[i];
|
||||
if (item && typeof item === "object") {
|
||||
if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) {
|
||||
val.splice(i, 1);
|
||||
} else {
|
||||
scanAndRemove(item);
|
||||
}
|
||||
} else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) {
|
||||
val.splice(i, 1);
|
||||
}
|
||||
}
|
||||
} else if (typeof val === "object") {
|
||||
scanAndRemove(val);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const nodeId in nodeOutputs) {
|
||||
scanAndRemove(nodeOutputs[nodeId]);
|
||||
}
|
||||
}
|
||||
|
||||
export function findNodeIdForImage(state, img) {
|
||||
if (!state || !state.nodeOutputs || !img) return null;
|
||||
for (const nodeId in state.nodeOutputs) {
|
||||
const out = state.nodeOutputs[nodeId];
|
||||
for (const key in out) {
|
||||
const val = out[key];
|
||||
if (Array.isArray(val)) {
|
||||
if (val.some(i => i && typeof i === "object" && i.filename === img.filename)) {
|
||||
return nodeId;
|
||||
}
|
||||
} else if (val && typeof val === "object" && val.filename === img.filename) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js";
|
||||
import { openFileOrFolder, removeImageFromNodeOutputs } from "./mediaActions.js";
|
||||
import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js";
|
||||
import { showFullscreenPreview } from "../utils/comparison.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
|
||||
export function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
|
||||
const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || [];
|
||||
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
|
||||
const bgImgSrc = lastImageAsset
|
||||
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
|
||||
: null;
|
||||
|
||||
if (!preview3D) {
|
||||
wrapper.innerHTML = "";
|
||||
preview3D = document.createElement("div");
|
||||
preview3D.className = "comfy-sidebar-3d-wrapper";
|
||||
|
||||
if (bgImgSrc) {
|
||||
const bgImg = document.createElement("img");
|
||||
bgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
bgImg.src = bgImgSrc;
|
||||
bgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.appendChild(bgImg);
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "comfy-sidebar-3d-overlay";
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-3d-badge";
|
||||
const ext = (img.filename || src).split('.').pop().toUpperCase();
|
||||
badge.textContent = ext || "3D";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-box comfy-sidebar-3d-icon";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-3d-title";
|
||||
title.textContent = img.filename || "3D Model";
|
||||
|
||||
overlay.append(badge, icon, title);
|
||||
preview3D.appendChild(overlay);
|
||||
wrapper.appendChild(preview3D);
|
||||
} else {
|
||||
const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img");
|
||||
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
|
||||
bgImg.src = bgImgSrc;
|
||||
} else if (!bgImg && bgImgSrc) {
|
||||
const newBgImg = document.createElement("img");
|
||||
newBgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.insertBefore(newBgImg, preview3D.firstChild);
|
||||
}
|
||||
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
|
||||
if (title) title.textContent = img.filename || "3D Model";
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = preview3D;
|
||||
|
||||
preview3D.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
};
|
||||
|
||||
preview3D.setAttribute("draggable", "true");
|
||||
preview3D.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const filename = img.filename || "model.glb";
|
||||
const mimeType = "application/octet-stream";
|
||||
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullUrl);
|
||||
e.dataTransfer.setData("text/plain", fullUrl);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
}
|
||||
|
||||
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
|
||||
const ext = filename.split('.').pop().toUpperCase();
|
||||
|
||||
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
||||
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
||||
|
||||
if (!previewAudio) {
|
||||
wrapper.innerHTML = "";
|
||||
previewAudio = document.createElement("div");
|
||||
previewAudio.className = "comfy-sidebar-audio-wrapper";
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-audio-title";
|
||||
title.textContent = filename;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-audio-badge";
|
||||
badge.textContent = ext || "AUDIO";
|
||||
|
||||
topRow.append(title, badge);
|
||||
|
||||
const centerArea = document.createElement("div");
|
||||
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" });
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
soundwave.style.width = "100%";
|
||||
const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12];
|
||||
barHeights.forEach((h, i) => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "comfy-sidebar-soundwave-bar";
|
||||
bar.style.height = `${h}px`;
|
||||
bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`;
|
||||
soundwave.appendChild(bar);
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
Object.assign(playBtn.style, {
|
||||
position: "absolute", width: "36px", height: "36px", borderRadius: "50%",
|
||||
background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5",
|
||||
transition: "all 0.15s ease", outline: "none"
|
||||
});
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
|
||||
centerArea.append(soundwave, playBtn);
|
||||
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" });
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
Object.assign(scrubber.style, {
|
||||
width: "100%", height: "4px", background: "#334155", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" });
|
||||
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) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
|
||||
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
|
||||
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
|
||||
|
||||
if (!previewFile) {
|
||||
wrapper.innerHTML = "";
|
||||
previewFile = document.createElement("div");
|
||||
previewFile.className = "comfy-sidebar-file-wrapper";
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" });
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-file-badge";
|
||||
badge.textContent = ext;
|
||||
topRow.appendChild(badge);
|
||||
|
||||
const centerArea = document.createElement("div");
|
||||
Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" });
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-file comfy-sidebar-file-icon";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-file-title";
|
||||
title.textContent = filename;
|
||||
title.title = filename;
|
||||
|
||||
centerArea.append(icon, title);
|
||||
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
|
||||
|
||||
const folderInfo = document.createElement("div");
|
||||
folderInfo.className = "comfy-sidebar-file-subtext";
|
||||
folderInfo.innerHTML = `<i class="pi pi-folder" style="font-size: 10px;"></i><span>${folderName}</span>`;
|
||||
|
||||
const openHint = document.createElement("div");
|
||||
openHint.className = "comfy-sidebar-file-subtext";
|
||||
openHint.innerHTML = `<i class="pi pi-external-link" style="font-size: 9px;"></i><span>Open Folder</span>`;
|
||||
|
||||
bottomRow.append(folderInfo, openHint);
|
||||
|
||||
previewFile.append(topRow, centerArea, bottomRow);
|
||||
wrapper.appendChild(previewFile);
|
||||
} else {
|
||||
const title = previewFile.querySelector(".comfy-sidebar-file-title");
|
||||
if (title) { title.textContent = filename; title.title = filename; }
|
||||
const badge = previewFile.querySelector(".comfy-sidebar-file-badge");
|
||||
if (badge) badge.textContent = ext;
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = previewFile;
|
||||
|
||||
previewFile.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
openFileOrFolder(img);
|
||||
};
|
||||
|
||||
previewFile.setAttribute("draggable", "true");
|
||||
previewFile.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const mimeType = "application/octet-stream";
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullUrl);
|
||||
e.dataTransfer.setData("text/plain", fullUrl);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
}
|
||||
|
||||
export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
}
|
||||
|
||||
const idx = cardObj.currentImageIndex;
|
||||
const img = state.images[idx];
|
||||
if (!img) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const randParam = state?.pid ? `&rand=${encodeURIComponent(state.pid)}` : "";
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}${randParam}`;
|
||||
const isVideo = isVideoFormat(src) || isVideoFormat(img.filename);
|
||||
const is3D = is3DFormat(src) || is3DFormat(img.filename);
|
||||
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
|
||||
const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes("."));
|
||||
|
||||
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
|
||||
if (!wrapper) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "comfy-sidebar-media-wrapper";
|
||||
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
|
||||
cardObj.grid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay");
|
||||
if (!ctrlOverlay) {
|
||||
ctrlOverlay = document.createElement("div");
|
||||
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
|
||||
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>Open Location</span>`;
|
||||
wrapper.appendChild(ctrlOverlay);
|
||||
}
|
||||
|
||||
if (is3D) {
|
||||
render3DCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
renderAudioCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaEl = wrapper.querySelector("img, video");
|
||||
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
if (needsRebuild) {
|
||||
if (mediaEl) mediaEl.remove();
|
||||
mediaEl = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
Object.assign(mediaEl.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element",
|
||||
zIndex: "1"
|
||||
});
|
||||
wrapper.insertBefore(mediaEl, wrapper.firstChild);
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = mediaEl;
|
||||
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
let activeSrc = mediaEl.currentSrc || mediaEl.src || src;
|
||||
// If it's a preview blob, snapshot the rendered pixels from the card so revoked blobs don't 404 in fullscreen
|
||||
if (activeSrc && activeSrc.startsWith("blob:") && mediaEl.naturalWidth > 0) {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = mediaEl.naturalWidth;
|
||||
canvas.height = mediaEl.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
|
||||
activeSrc = canvas.toDataURL("image/png");
|
||||
} catch (_) {}
|
||||
}
|
||||
showFullscreenPreview([activeSrc], ev.shiftKey, state?.pid);
|
||||
};
|
||||
|
||||
mediaEl.onerror = () => {
|
||||
mediaEl.onerror = null;
|
||||
if ((src && src.startsWith("blob:")) || is3D || isAudio) return;
|
||||
|
||||
const cardId = cardObj.element?.id || "";
|
||||
|
||||
// 1. If an intermediate output item is missing, remove only this item
|
||||
if (cardId.startsWith("card-submenu-")) {
|
||||
const parentState = store.getPrompt(state.pid);
|
||||
if (parentState && parentState.nodeOutputs) {
|
||||
if (state.nodeId && parentState.nodeOutputs[state.nodeId]) {
|
||||
delete parentState.nodeOutputs[state.nodeId];
|
||||
} else {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
store.updatePrompt(parentState.pid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If a batch item is missing, remove only this item from the batch
|
||||
if (cardId.startsWith("card-batch-")) {
|
||||
const parentPid = state.parentPromptId || state.pid;
|
||||
const parentState = store.getPrompt(parentPid);
|
||||
if (parentState) {
|
||||
if (Array.isArray(parentState.images)) {
|
||||
const idx = parentState.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
|
||||
if (idx > -1) parentState.images.splice(idx, 1);
|
||||
}
|
||||
if (parentState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
store.updatePrompt(parentPid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Main card: if this file is gone, clean it up
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
if (liveState) {
|
||||
if (Array.isArray(liveState.images)) {
|
||||
liveState.images = liveState.images.filter(i =>
|
||||
!(i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""))
|
||||
);
|
||||
}
|
||||
if (liveState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
}
|
||||
|
||||
// If the card still has other images (e.g. from a batch), persist and switch to next image
|
||||
if (liveState.images && liveState.images.length > 0) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the card has text outputs or is still in progress, persist
|
||||
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only delete the main card when all items belonging to it are gone
|
||||
store.deletePrompt(liveState.pid);
|
||||
cardObj.element?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
if (cardObj.dimEl && width && height) {
|
||||
// Don't show the dimension badge for active/running preview frames
|
||||
if (state.status === "active") {
|
||||
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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-141
@@ -1,141 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
};
|
||||
|
||||
export const is3DFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".glb") || s.includes(".gltf") || s.includes(".obj") ||
|
||||
s.includes(".ply") || s.includes(".stl") || s.includes(".splat") ||
|
||||
s.includes(".spz") || s.includes(".ksplat") || s.includes(".fbx");
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (!workflow || !Array.isArray(workflow.nodes)) {
|
||||
return false;
|
||||
}
|
||||
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
|
||||
return !!(node && node.properties && node.properties.ignoreInQueue);
|
||||
}
|
||||
|
||||
export function findImagesInOutputs(outputs, rawWorkflow) {
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
|
||||
const scan = (obj) => {
|
||||
if (!obj) return;
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => scan(item));
|
||||
} else if (typeof obj === "object") {
|
||||
if (obj.filename || obj.name) {
|
||||
const fname = obj.filename || obj.name;
|
||||
if (typeof fname === "string" && (fname.includes(".") || obj.type)) {
|
||||
list.push({
|
||||
filename: fname,
|
||||
subfolder: obj.subfolder || "",
|
||||
type: obj.type || "output"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const key in obj) {
|
||||
scan(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
scan(outputs[nodeId]);
|
||||
}
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
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 getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return [];
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return [];
|
||||
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;
|
||||
}
|
||||
@@ -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,213 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { syncAllNodeBadges } from "../comfy/nodes.js";
|
||||
|
||||
export function showNotification(message, type = "success", title = null) {
|
||||
const summary = title || (type === "error" ? "Error" : type === "warn" ? "Warning" : "Comfy Sidebar");
|
||||
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: type === "error" ? "error" : type === "warn" ? "warn" : "success",
|
||||
summary,
|
||||
detail: message,
|
||||
life: 4000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Elegant non-blocking fallback if native toast is unavailable
|
||||
const id = "comfy-sidebar-toast";
|
||||
let toast = document.getElementById(id);
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = id;
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid ${type === "error" ? "#ef4444" : type === "warn" ? "#f59e0b" : "#10b981"};
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 99999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
max-width: 380px;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
`;
|
||||
toast.innerHTML = `<strong style="color:${type === "error" ? "#f87171" : type === "warn" ? "#fbbf24" : "#34d399"}">${summary}</strong><div style="margin-top:2px;">${message}</div>`;
|
||||
requestAnimationFrame(() => {
|
||||
toast.style.opacity = "1";
|
||||
toast.style.transform = "translateY(0)";
|
||||
});
|
||||
clearTimeout(toast._timeout);
|
||||
toast._timeout = setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateY(10px)";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
export async function renumberNodesTopologically() {
|
||||
if (!app.graph || !app.graph._nodes || app.graph._nodes.length === 0) {
|
||||
showNotification("No nodes found on canvas to renumber.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
"Renumbering will rewrite all node IDs on the canvas in topological execution order.\n\n" +
|
||||
"This will fix intermediate node outputs showing as the final result, but may break external scripts that rely on specific node IDs\n\n" +
|
||||
"Do you want to proceed?"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
// Compute LiteGraph's topological execution order
|
||||
if (typeof app.graph.computeExecutionOrder === "function") {
|
||||
app.graph.computeExecutionOrder(false);
|
||||
}
|
||||
|
||||
// Sort nodes by order (lowest executes first; highest executes last)
|
||||
const sortedNodes = [...app.graph._nodes].sort((a, b) => {
|
||||
const orderA = typeof a.order === "number" ? a.order : Infinity;
|
||||
const orderB = typeof b.order === "number" ? b.order : Infinity;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
|
||||
const posXA = Array.isArray(a.pos) ? a.pos[0] : 0;
|
||||
const posXB = Array.isArray(b.pos) ? b.pos[0] : 0;
|
||||
if (posXA !== posXB) return posXA - posXB;
|
||||
|
||||
const posYA = Array.isArray(a.pos) ? a.pos[1] : 0;
|
||||
const posYB = Array.isArray(b.pos) ? b.pos[1] : 0;
|
||||
return posYA - posYB;
|
||||
});
|
||||
|
||||
const oldToNew = new Map();
|
||||
sortedNodes.forEach((node, index) => {
|
||||
const newId = index + 1;
|
||||
oldToNew.set(node.id, newId);
|
||||
oldToNew.set(String(node.id), newId);
|
||||
});
|
||||
|
||||
// 1. Remap node IDs in-place on app.graph
|
||||
app.graph._nodes_by_id = {};
|
||||
sortedNodes.forEach(node => {
|
||||
const oldId = node.id;
|
||||
const newId = oldToNew.get(oldId);
|
||||
if (newId != null) {
|
||||
// Update internal widget IDs like "$2034-0" (PreviewBridge)
|
||||
if (Array.isArray(node.widgets)) {
|
||||
for (const w of node.widgets) {
|
||||
if (typeof w.value === "string" && w.value.startsWith(`$${oldId}-`)) {
|
||||
w.value = w.value.replace(`$${oldId}-`, `$${newId}-`);
|
||||
}
|
||||
}
|
||||
}
|
||||
node.id = newId;
|
||||
app.graph._nodes_by_id[newId] = node;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Remap links
|
||||
if (app.graph.links) {
|
||||
const links = app.graph.links instanceof Map
|
||||
? Array.from(app.graph.links.values())
|
||||
: Object.values(app.graph.links);
|
||||
|
||||
links.forEach(link => {
|
||||
if (!link) return;
|
||||
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
|
||||
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
|
||||
});
|
||||
}
|
||||
|
||||
app.graph.last_node_id = sortedNodes.length;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas?.draw) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
syncAllNodeBadges();
|
||||
|
||||
const lastNode = sortedNodes[sortedNodes.length - 1];
|
||||
const lastNodeName = lastNode ? (lastNode.title || lastNode.type) : "SaveImage";
|
||||
showNotification(`Renumbered ${sortedNodes.length} nodes by execution order. Latest node: #${lastNode.id} (${lastNodeName}).`, "success");
|
||||
} catch (err) {
|
||||
console.error("ComfySidebar: Failed to renumber nodes", err);
|
||||
showNotification("Failed to renumber nodes: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setManualNodeId(targetNode = null) {
|
||||
if (!app.graph) return;
|
||||
const node = targetNode || (app.canvas?.selected_nodes ? Object.values(app.canvas.selected_nodes)[0] : null);
|
||||
if (!node) {
|
||||
showNotification("Please select a node first to change its ID.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentId = node.id;
|
||||
const input = prompt(`Enter new ID for node #${currentId} (${node.title || node.type}):`, String(currentId));
|
||||
if (!input) return;
|
||||
|
||||
const newId = parseInt(input.trim(), 10);
|
||||
if (isNaN(newId) || newId <= 0) {
|
||||
showNotification("Please enter a valid positive number.", "error");
|
||||
return;
|
||||
}
|
||||
if (newId === currentId) return;
|
||||
|
||||
try {
|
||||
const existingNode = app.graph.getNodeById ? app.graph.getNodeById(newId) : null;
|
||||
if (existingNode && existingNode !== node) {
|
||||
const confirmSwap = confirm(`Node ID #${newId} is already used by "${existingNode.title || existingNode.type}". Swap their IDs?`);
|
||||
if (!confirmSwap) return;
|
||||
}
|
||||
|
||||
const oldToNew = new Map();
|
||||
if (existingNode && existingNode !== node) {
|
||||
oldToNew.set(currentId, newId);
|
||||
oldToNew.set(newId, currentId);
|
||||
existingNode.id = currentId;
|
||||
} else {
|
||||
oldToNew.set(currentId, newId);
|
||||
}
|
||||
node.id = newId;
|
||||
|
||||
// Rebuild lookup map
|
||||
app.graph._nodes_by_id = {};
|
||||
for (const n of app.graph._nodes) {
|
||||
app.graph._nodes_by_id[n.id] = n;
|
||||
}
|
||||
|
||||
// Remap links
|
||||
if (app.graph.links) {
|
||||
const links = app.graph.links instanceof Map
|
||||
? Array.from(app.graph.links.values())
|
||||
: Object.values(app.graph.links);
|
||||
|
||||
links.forEach(link => {
|
||||
if (!link) return;
|
||||
if (oldToNew.has(link.origin_id)) link.origin_id = oldToNew.get(link.origin_id);
|
||||
if (oldToNew.has(link.target_id)) link.target_id = oldToNew.get(link.target_id);
|
||||
});
|
||||
}
|
||||
|
||||
app.graph.last_node_id = Math.max(app.graph.last_node_id || 0, newId, app.graph._nodes.length);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas?.draw) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
syncAllNodeBadges();
|
||||
showNotification(`Assigned node ID #${newId} to "${node.title || node.type}".`, "success");
|
||||
} catch (err) {
|
||||
console.error("ComfySidebar: Failed to set node ID", err);
|
||||
showNotification("Failed to set node ID: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
@@ -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: 0px; break-inside: avoid;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
border: 2px solid var(--border-color, #333);
|
||||
color: var(--comfy-input-color, var(--fg-color, #eee));
|
||||
}
|
||||
.comfy-sidebar-card:hover {
|
||||
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
|
||||
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
|
||||
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
|
||||
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
|
||||
|
||||
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Prevent action hover buttons (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,560 @@
|
||||
import { is3DFormat, getFilenameFromUrl } from "./utils.js";
|
||||
import { SidebarOverlay } from "./overlay.js";
|
||||
|
||||
let cachedThreeLibs = null;
|
||||
|
||||
async function getThreeLibs() {
|
||||
if (cachedThreeLibs) return cachedThreeLibs;
|
||||
|
||||
// Check if Three.js was already loaded on window by another ComfyUI 3D node
|
||||
if (window.THREE) {
|
||||
const OrbitControls = window.THREE.OrbitControls || window.OrbitControls;
|
||||
const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader;
|
||||
if (OrbitControls && GLTFLoader) {
|
||||
cachedThreeLibs = {
|
||||
...window.THREE,
|
||||
OrbitControls,
|
||||
GLTFLoader,
|
||||
OBJLoader: window.THREE.OBJLoader || window.OBJLoader,
|
||||
STLLoader: window.THREE.STLLoader || window.STLLoader,
|
||||
PLYLoader: window.THREE.PLYLoader || window.PLYLoader
|
||||
};
|
||||
return cachedThreeLibs;
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromBase = async (base) => {
|
||||
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
|
||||
import(`${base}three@0.170.0`),
|
||||
import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null)
|
||||
]);
|
||||
return { three, controls, gltf, obj, stl, ply };
|
||||
};
|
||||
|
||||
try {
|
||||
const base = "https://esm.sh/";
|
||||
window.__COMFY_SIDEBAR_THREE_BASE = base;
|
||||
const { three, controls, gltf, obj, stl, ply } = await loadFromBase(base);
|
||||
cachedThreeLibs = {
|
||||
...three,
|
||||
OrbitControls: controls.OrbitControls || controls.default || controls,
|
||||
GLTFLoader: gltf.GLTFLoader || gltf.default || gltf,
|
||||
OBJLoader: obj?.OBJLoader || obj?.default || obj,
|
||||
STLLoader: stl?.STLLoader || stl?.default || stl,
|
||||
PLYLoader: ply?.PLYLoader || ply?.default || ply
|
||||
};
|
||||
return cachedThreeLibs;
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to load Three.js libraries:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
const overlay = new SidebarOverlay({
|
||||
className: "comfy-sidebar-3d-overlay-root",
|
||||
onDestroy
|
||||
});
|
||||
|
||||
const header = document.createElement("div");
|
||||
Object.assign(header.style, {
|
||||
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", gap: "16px", zIndex: "30", color: "#aaa", fontSize: "12px",
|
||||
fontFamily: "monospace", pointerEvents: "none", background: "rgba(10,10,10,0.75)",
|
||||
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(4px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
|
||||
});
|
||||
const titleText = document.createElement("span");
|
||||
titleText.textContent = getFilenameFromUrl(baseSrc) || "3D Model";
|
||||
header.appendChild(titleText);
|
||||
overlay.container.appendChild(header);
|
||||
|
||||
const canvasContainer = document.createElement("div");
|
||||
Object.assign(canvasContainer.style, {
|
||||
width: "100%", height: "100%", position: "relative",
|
||||
display: "flex", alignItems: "center", justifyContent: "center"
|
||||
});
|
||||
overlay.container.appendChild(canvasContainer);
|
||||
|
||||
const loadingSpinner = document.createElement("div");
|
||||
Object.assign(loadingSpinner.style, {
|
||||
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
|
||||
color: "#60a5fa", fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
|
||||
background: "rgba(0,0,0,0.8)", padding: "10px 18px", borderRadius: "8px",
|
||||
display: "flex", alignItems: "center", gap: "10px"
|
||||
});
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
|
||||
overlay.container.appendChild(loadingSpinner);
|
||||
|
||||
const settingsPanel = document.createElement("div");
|
||||
Object.assign(settingsPanel.style, {
|
||||
position: "absolute", top: "60px", right: "20px", width: "230px",
|
||||
background: "rgba(18, 18, 24, 0.9)", backdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.12)", borderRadius: "8px",
|
||||
padding: "14px", zIndex: "35", color: "#e2e8f0", fontSize: "11px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", display: "none", flexDirection: "column", gap: "12px"
|
||||
});
|
||||
|
||||
const createFormGroup = (label, inputEl) => {
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = label;
|
||||
Object.assign(lbl.style, { color: "#94a3b8", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
|
||||
row.append(lbl, inputEl);
|
||||
return row;
|
||||
};
|
||||
|
||||
const createSelect = (options, value, onChange) => {
|
||||
const sel = document.createElement("select");
|
||||
Object.assign(sel.style, {
|
||||
background: "#252530", color: "#eee", border: "1px solid #444",
|
||||
borderRadius: "4px", padding: "4px 6px", fontSize: "11px", outline: "none"
|
||||
});
|
||||
options.forEach(opt => {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.label;
|
||||
if (opt.value === value) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = (e) => onChange(e.target.value);
|
||||
return sel;
|
||||
};
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
Object.assign(toolbar.style, {
|
||||
position: "absolute", bottom: "20px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", gap: "8px", zIndex: "30", background: "rgba(20,20,25,0.85)",
|
||||
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(6px)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.1)",
|
||||
alignItems: "center"
|
||||
});
|
||||
|
||||
const createToolbarBtn = (iconClass, label, activeDefault, onClick) => {
|
||||
const btn = document.createElement("button");
|
||||
let active = activeDefault;
|
||||
btn.className = "comfy-sidebar-3d-tool-btn";
|
||||
Object.assign(btn.style, {
|
||||
background: active ? "#3b82f6" : "transparent", color: active ? "#fff" : "#aaa",
|
||||
border: "1px solid " + (active ? "#3b82f6" : "#444"), borderRadius: "4px",
|
||||
padding: "4px 10px", fontSize: "11px", cursor: "pointer", display: "flex",
|
||||
alignItems: "center", gap: "6px", transition: "all 0.15s ease"
|
||||
});
|
||||
btn.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
active = !active;
|
||||
btn.style.background = active ? "#3b82f6" : "transparent";
|
||||
btn.style.color = active ? "#fff" : "#aaa";
|
||||
btn.style.borderColor = active ? "#3b82f6" : "#444";
|
||||
onClick(active);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
let renderer, scene, camera, cameraPersp, cameraOrtho, controls, currentModel, gridHelper, animId;
|
||||
let ambientLight, dirLight1, dirLight2;
|
||||
let autoRotate = false, showWireframe = false, currentMaterialMode = "original", currentUpDirection = "y-up";
|
||||
let lightMultiplier = 1.0;
|
||||
let THREE = null;
|
||||
|
||||
const applyMaterialMode = () => {
|
||||
if (!currentModel || !THREE) return;
|
||||
currentModel.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
if (currentMaterialMode === "clay") {
|
||||
child.material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.6, metalness: 0.1 });
|
||||
} else if (currentMaterialMode === "normal") {
|
||||
child.material = new THREE.MeshNormalMaterial();
|
||||
} else {
|
||||
child.material = child._originalMaterial || child.material;
|
||||
}
|
||||
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(m => m.wireframe = showWireframe);
|
||||
} else if (child.material) {
|
||||
child.material.wireframe = showWireframe;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyUpDirection = () => {
|
||||
if (!currentModel || !THREE) return;
|
||||
if (currentUpDirection === "z-up") {
|
||||
currentModel.rotation.x = -Math.PI / 2;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
} else if (currentUpDirection === "z-down") {
|
||||
currentModel.rotation.x = Math.PI / 2;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
} else {
|
||||
currentModel.rotation.x = 0;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
}
|
||||
if (controls && camera) {
|
||||
fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
if (gridHelper) gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportModel = async (format) => {
|
||||
if (!currentModel) return;
|
||||
const baseName = (titleText.textContent || "model").replace(/\.[^/.]+$/, "");
|
||||
|
||||
try {
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
|
||||
|
||||
const base = window.__COMFY_SIDEBAR_THREE_BASE || "https://esm.sh/";
|
||||
if (format === "OBJ") {
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
|
||||
const OBJExporter = mod.OBJExporter || mod.default || mod;
|
||||
const exporter = new OBJExporter();
|
||||
const result = exporter.parse(currentModel);
|
||||
const blob = new Blob([result], { type: "text/plain" });
|
||||
downloadBlob(blob, `${baseName}.obj`);
|
||||
} else if (format === "GLB") {
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`);
|
||||
const GLTFExporter = mod.GLTFExporter || mod.default || mod;
|
||||
const exporter = new GLTFExporter();
|
||||
exporter.parse(currentModel, (gltf) => {
|
||||
const blob = new Blob([gltf], { type: "application/octet-stream" });
|
||||
downloadBlob(blob, `${baseName}.glb`);
|
||||
}, (err) => { console.error(err); }, { binary: true });
|
||||
} else if (format === "STL") {
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`);
|
||||
const STLExporter = mod.STLExporter || mod.default || mod;
|
||||
const exporter = new STLExporter();
|
||||
const result = exporter.parse(currentModel, { binary: true });
|
||||
const blob = new Blob([result], { type: "application/octet-stream" });
|
||||
downloadBlob(blob, `${baseName}.stl`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Export failed", err);
|
||||
} finally {
|
||||
loadingSpinner.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup3D = () => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
if (controls) controls.dispose();
|
||||
if (currentModel && scene) scene.remove(currentModel);
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
}
|
||||
};
|
||||
|
||||
overlay.addCleanup(cleanup3D);
|
||||
|
||||
const initSceneAndLoad = async (srcUrl) => {
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
|
||||
titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model";
|
||||
|
||||
THREE = await getThreeLibs();
|
||||
if (!THREE) {
|
||||
loadingSpinner.innerHTML = `
|
||||
<div style="text-align: center; max-width: 340px; line-height: 1.4; padding: 6px;">
|
||||
<span style="color: #f87171; font-weight: bold; font-size: 13px;">Failed to load 3D engine.</span><br>
|
||||
<span style="font-size: 11px; color: #cbd5e1; display: inline-block; margin-top: 4px;">
|
||||
Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0e0e12);
|
||||
|
||||
const aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
|
||||
cameraPersp = new THREE.PerspectiveCamera(45, aspect, 0.01, 1000);
|
||||
cameraPersp.position.set(0, 1.5, 3.5);
|
||||
|
||||
const d = 2;
|
||||
cameraOrtho = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.01, 1000);
|
||||
cameraOrtho.position.set(0, 1.5, 3.5);
|
||||
|
||||
camera = cameraPersp;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
canvasContainer.appendChild(renderer.domElement);
|
||||
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
|
||||
ambientLight = new THREE.AmbientLight(0xffffff, 1.2 * lightMultiplier);
|
||||
scene.add(ambientLight);
|
||||
|
||||
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.8 * lightMultiplier);
|
||||
dirLight1.position.set(5, 10, 7);
|
||||
scene.add(dirLight1);
|
||||
|
||||
dirLight2 = new THREE.DirectionalLight(0x90b0ff, 0.8 * lightMultiplier);
|
||||
dirLight2.position.set(-5, -5, -5);
|
||||
scene.add(dirLight2);
|
||||
|
||||
gridHelper = new THREE.GridHelper(10, 20, 0x3b82f6, 0x22222a);
|
||||
gridHelper.position.y = 0;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const bgColorInput = document.createElement("input");
|
||||
bgColorInput.type = "color";
|
||||
bgColorInput.value = "#0e0e12";
|
||||
Object.assign(bgColorInput.style, { width: "100%", height: "26px", borderRadius: "4px", border: "none", cursor: "pointer" });
|
||||
bgColorInput.oninput = (e) => { scene.background.set(e.target.value); };
|
||||
|
||||
const upDirSelect = createSelect([
|
||||
{ value: "y-up", label: "Original (Y-Up)" },
|
||||
{ value: "z-up", label: "Z-Up (+90°)" },
|
||||
{ value: "z-down", label: "Inverted (-90°)" }
|
||||
], currentUpDirection, (val) => {
|
||||
currentUpDirection = val;
|
||||
applyUpDirection();
|
||||
});
|
||||
|
||||
const matModeSelect = createSelect([
|
||||
{ value: "original", label: "Original / Textured" },
|
||||
{ value: "clay", label: "Clay / Studio Matte" },
|
||||
{ value: "normal", label: "Normals" }
|
||||
], currentMaterialMode, (val) => {
|
||||
currentMaterialMode = val;
|
||||
applyMaterialMode();
|
||||
});
|
||||
|
||||
const camTypeSelect = createSelect([
|
||||
{ value: "perspective", label: "Perspective" },
|
||||
{ value: "orthographic", label: "Orthographic" }
|
||||
], "perspective", (val) => {
|
||||
const prevPos = camera.position.clone();
|
||||
const prevTarget = controls.target.clone();
|
||||
|
||||
if (val === "orthographic") camera = cameraOrtho;
|
||||
else camera = cameraPersp;
|
||||
|
||||
camera.position.copy(prevPos);
|
||||
controls.object = camera;
|
||||
controls.target.copy(prevTarget);
|
||||
controls.update();
|
||||
});
|
||||
|
||||
const fovContainer = document.createElement("div");
|
||||
Object.assign(fovContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
const fovSlider = document.createElement("input");
|
||||
Object.assign(fovSlider, { type: "range", min: "15", max: "120", value: "45" });
|
||||
fovSlider.style.flex = "1";
|
||||
const fovVal = document.createElement("span");
|
||||
fovVal.textContent = "45°";
|
||||
fovSlider.oninput = (e) => {
|
||||
const v = Number(e.target.value);
|
||||
fovVal.textContent = `${v}°`;
|
||||
cameraPersp.fov = v;
|
||||
cameraPersp.updateProjectionMatrix();
|
||||
};
|
||||
fovContainer.append(fovSlider, fovVal);
|
||||
|
||||
const lightContainer = document.createElement("div");
|
||||
Object.assign(lightContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
const lightSlider = document.createElement("input");
|
||||
Object.assign(lightSlider, { type: "range", min: "0.1", max: "3.0", step: "0.1", value: "1.0" });
|
||||
lightSlider.style.flex = "1";
|
||||
const lightVal = document.createElement("span");
|
||||
lightVal.textContent = "1.0x";
|
||||
lightSlider.oninput = (e) => {
|
||||
lightMultiplier = Number(e.target.value);
|
||||
lightVal.textContent = `${lightMultiplier.toFixed(1)}x`;
|
||||
ambientLight.intensity = 1.2 * lightMultiplier;
|
||||
dirLight1.intensity = 1.8 * lightMultiplier;
|
||||
dirLight2.intensity = 0.8 * lightMultiplier;
|
||||
};
|
||||
lightContainer.append(lightSlider, lightVal);
|
||||
|
||||
settingsPanel.append(
|
||||
createFormGroup("Background Color", bgColorInput),
|
||||
createFormGroup("Up Direction", upDirSelect),
|
||||
createFormGroup("Material Mode", matModeSelect),
|
||||
createFormGroup("Camera Type", camTypeSelect),
|
||||
createFormGroup("FOV", fovContainer),
|
||||
createFormGroup("Light Intensity", lightContainer)
|
||||
);
|
||||
overlay.container.appendChild(settingsPanel);
|
||||
|
||||
const btnGrid = createToolbarBtn("pi pi-table", "Grid", true, (v) => { gridHelper.visible = v; });
|
||||
const btnRotate = createToolbarBtn("pi pi-sync", "Auto-Rotate", false, (v) => { autoRotate = v; });
|
||||
const btnWire = createToolbarBtn("pi pi-box", "Wireframe", false, (v) => {
|
||||
showWireframe = v;
|
||||
applyMaterialMode();
|
||||
});
|
||||
|
||||
const btnResetCam = document.createElement("button");
|
||||
Object.assign(btnResetCam.style, {
|
||||
background: "transparent", color: "#aaa", border: "1px solid #444",
|
||||
borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", gap: "6px"
|
||||
});
|
||||
btnResetCam.innerHTML = `<i class="pi pi-compass"></i><span>Reset View</span>`;
|
||||
btnResetCam.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (currentModel) fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
};
|
||||
|
||||
const btnSettings = createToolbarBtn("pi pi-sliders-h", "Settings", false, (v) => {
|
||||
settingsPanel.style.display = v ? "flex" : "none";
|
||||
});
|
||||
|
||||
const exportSelect = createSelect([
|
||||
{ value: "", label: "Export As..." },
|
||||
{ value: "OBJ", label: "OBJ (.obj)" },
|
||||
{ value: "GLB", label: "GLB / GLTF" },
|
||||
{ value: "STL", label: "STL (.stl)" }
|
||||
], "", (val) => {
|
||||
if (val) {
|
||||
exportModel(val);
|
||||
exportSelect.value = "";
|
||||
}
|
||||
});
|
||||
exportSelect.style.background = "#1e293b";
|
||||
exportSelect.style.borderColor = "#3b82f6";
|
||||
exportSelect.style.color = "#93c5fd";
|
||||
|
||||
toolbar.append(btnGrid, btnWire, btnRotate, btnResetCam, btnSettings, exportSelect);
|
||||
overlay.container.appendChild(toolbar);
|
||||
|
||||
const onResize = () => {
|
||||
if (!canvasContainer || !renderer || !cameraPersp || !cameraOrtho) return;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = canvasContainer.clientHeight;
|
||||
const aspect = width / height;
|
||||
|
||||
cameraPersp.aspect = aspect;
|
||||
cameraPersp.updateProjectionMatrix();
|
||||
|
||||
const d = 2;
|
||||
cameraOrtho.left = -d * aspect;
|
||||
cameraOrtho.right = d * aspect;
|
||||
cameraOrtho.top = d;
|
||||
cameraOrtho.bottom = -d;
|
||||
cameraOrtho.updateProjectionMatrix();
|
||||
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
overlay.addCleanup(() => window.removeEventListener("resize", onResize));
|
||||
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
if (autoRotate && currentModel) currentModel.rotation.y += 0.008;
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
}
|
||||
|
||||
if (currentModel) {
|
||||
scene.remove(currentModel);
|
||||
currentModel = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const filename = getFilenameFromUrl(srcUrl);
|
||||
const ext = (filename.split(".").pop() || "glb").toLowerCase();
|
||||
let loadedObj = null;
|
||||
|
||||
if (ext === "obj" && THREE.OBJLoader) {
|
||||
const loader = new THREE.OBJLoader();
|
||||
loadedObj = await loader.loadAsync(srcUrl);
|
||||
} else if (ext === "stl" && THREE.STLLoader) {
|
||||
const loader = new THREE.STLLoader();
|
||||
const geometry = await loader.loadAsync(srcUrl);
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4, metalness: 0.2 });
|
||||
loadedObj = new THREE.Mesh(geometry, material);
|
||||
} else if (ext === "ply" && THREE.PLYLoader) {
|
||||
const loader = new THREE.PLYLoader();
|
||||
const geometry = await loader.loadAsync(srcUrl);
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4 });
|
||||
loadedObj = new THREE.Mesh(geometry, material);
|
||||
} else if (THREE.GLTFLoader) {
|
||||
const loader = new THREE.GLTFLoader();
|
||||
const gltfData = await loader.loadAsync(srcUrl);
|
||||
loadedObj = gltfData.scene || gltfData.scenes?.[0];
|
||||
}
|
||||
|
||||
if (loadedObj) {
|
||||
currentModel = loadedObj;
|
||||
currentModel.traverse((child) => {
|
||||
if (child.isMesh && child.material) child._originalMaterial = child.material;
|
||||
});
|
||||
applyMaterialMode();
|
||||
applyUpDirection();
|
||||
scene.add(currentModel);
|
||||
fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to parse 3D model", err);
|
||||
loadingSpinner.innerHTML = `<span style="color:#f87171;">Failed to load 3D format</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingSpinner.style.display = "none";
|
||||
};
|
||||
|
||||
function fitCameraToObject(cam, obj, ctrl, THREE) {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
|
||||
if (cam.isPerspectiveCamera) {
|
||||
const fov = cam.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
|
||||
cam.position.set(center.x, center.y + size.y * 0.2, center.z + cameraZ);
|
||||
} else if (cam.isOrthographicCamera) {
|
||||
cam.position.set(center.x, center.y + size.y * 0.2, center.z + maxDim * 2);
|
||||
}
|
||||
|
||||
cam.lookAt(center);
|
||||
ctrl.target.copy(center);
|
||||
ctrl.update();
|
||||
}
|
||||
|
||||
initSceneAndLoad(baseSrc);
|
||||
|
||||
return {
|
||||
is3D: true,
|
||||
loadTarget(targetSrc) {
|
||||
if (!is3DFormat(targetSrc)) {
|
||||
overlay.destroy();
|
||||
onSwitchMedia(targetSrc);
|
||||
return;
|
||||
}
|
||||
initSceneAndLoad(targetSrc);
|
||||
},
|
||||
destroy: () => overlay.destroy()
|
||||
};
|
||||
}
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
[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.3.0"
|
||||
version = "1.6.2"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"send2trash>=1.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
send2trash>=1.8.0
|
||||
Reference in New Issue
Block a user