Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7448dd80 | ||
|
|
b93413a729 | ||
|
|
782d30b490 | ||
|
|
0a2db6b85f | ||
|
|
5a4c9f99a5 | ||
|
|
141a0872d5 | ||
|
|
108ac61bec | ||
|
|
b31cfb5f8a |
@@ -17,6 +17,8 @@ A compact sidebar for ComfyUI that replaces the stock queue/history UI with a fa
|
||||
* Optionally replace the stock Job History sidebar.
|
||||
* Optional unified top bar for workflow tabs and controls.
|
||||
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
|
||||
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
|
||||
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
@@ -52,9 +54,10 @@ 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 |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open file location/alternative action |
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
|
||||
+179
-4
@@ -1,8 +1,183 @@
|
||||
# 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)
|
||||
return real_target == real_base or real_target.startswith(real_base + os.sep)
|
||||
|
||||
|
||||
def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
"""Gathers all potential ComfyUI base directories (output, model folders, extra paths)."""
|
||||
roots = []
|
||||
try:
|
||||
roots.append(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Include specific model categories (loras, checkpoints, etc.)
|
||||
categories = ["loras", "checkpoints", "vae", "unet", "clip", "embeddings", "upscale_models"]
|
||||
if subfolder and subfolder in folder_paths.folder_names_and_paths:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(subfolder))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cat in categories:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(cat))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
roots.append(folder_paths.get_input_directory())
|
||||
roots.append(folder_paths.get_temp_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplicate while preserving existence
|
||||
unique = []
|
||||
for r in roots:
|
||||
if r and r not in unique and os.path.exists(r):
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output"):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories."""
|
||||
candidates = get_all_candidate_roots(subfolder, folder_type)
|
||||
|
||||
if filename:
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
p1 = os.path.join(root, subfolder, filename)
|
||||
if os.path.isfile(p1) and is_path_safe(root, p1):
|
||||
return p1, root
|
||||
p2 = os.path.join(root, filename)
|
||||
if os.path.isfile(p2) and is_path_safe(root, p2):
|
||||
return p2, root
|
||||
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
d1 = os.path.join(root, subfolder)
|
||||
if os.path.isdir(d1) and is_path_safe(root, d1):
|
||||
return d1, root
|
||||
if os.path.isdir(root):
|
||||
return root, root
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def open_file_in_os(target_path):
|
||||
"""Fast, native cross-platform explorer launch."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
norm_path = os.path.normpath(target_path)
|
||||
if not os.path.exists(norm_path):
|
||||
raise FileNotFoundError(f"Path not found: {norm_path}")
|
||||
if os.path.isfile(norm_path):
|
||||
# Passed as a single argument so Windows Explorer highlights the file properly
|
||||
subprocess.Popen(["explorer", f"/select,{norm_path}"])
|
||||
else:
|
||||
os.startfile(norm_path)
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
if not os.path.exists(target_path):
|
||||
raise FileNotFoundError(f"Path not found: {target_path}")
|
||||
if os.path.isfile(target_path):
|
||||
subprocess.Popen(["open", "-R", target_path])
|
||||
else:
|
||||
subprocess.Popen(["open", target_path])
|
||||
|
||||
else: # Linux / Unix
|
||||
folder = os.path.dirname(target_path) if os.path.isfile(target_path) else target_path
|
||||
if not os.path.exists(folder):
|
||||
raise FileNotFoundError(f"Path not found: {folder}")
|
||||
if "DISPLAY" in os.environ or "WAYLAND_DISPLAY" in os.environ:
|
||||
subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
|
||||
async def open_folder_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = (data.get("filename") or "").strip()
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
target_path, _ = resolve_existing_path(filename, subfolder, folder_type)
|
||||
if not target_path:
|
||||
target_path = folder_paths.get_output_directory()
|
||||
|
||||
open_file_in_os(target_path)
|
||||
return web.json_response({"success": True, "path": target_path})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/delete-file")
|
||||
async def delete_file_handler(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = (data.get("filename") or "").strip()
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
if not filename:
|
||||
return web.json_response({"error": "No filename provided"}, status=400)
|
||||
|
||||
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
|
||||
|
||||
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
|
||||
return web.json_response({"error": "File not found or unsafe path"}, status=404)
|
||||
|
||||
# Try moving to Recycle Bin / Trash first, fallback to os.remove
|
||||
try:
|
||||
import send2trash
|
||||
send2trash.send2trash(target_file)
|
||||
except Exception:
|
||||
os.remove(target_file)
|
||||
|
||||
return web.json_response({"success": True, "deleted": target_file})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/comfy-sidebar/promote-output")
|
||||
async def promote_output_handler(request):
|
||||
try:
|
||||
import shutil
|
||||
data = await request.json()
|
||||
raw_filename = (data.get("filename") or "").strip()
|
||||
filename = os.path.basename(raw_filename)
|
||||
subfolder = (data.get("subfolder") or "").strip()
|
||||
folder_type = (data.get("type") or "output").strip()
|
||||
|
||||
target_file, root = resolve_existing_path(raw_filename, subfolder, folder_type)
|
||||
if not target_file or not os.path.isfile(target_file):
|
||||
return web.json_response({"error": "File not found"}, status=404)
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
dest_path = os.path.join(input_dir, filename)
|
||||
if not is_path_safe(input_dir, dest_path):
|
||||
return web.json_response({"error": "Unsafe destination path"}, status=403)
|
||||
|
||||
shutil.copy2(target_file, dest_path)
|
||||
return web.json_response({"name": filename})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
+30
-31
@@ -234,10 +234,19 @@ const createMediaElement = (src, muted = false) => {
|
||||
el.controls = false;
|
||||
}
|
||||
Object.assign(el.style, {
|
||||
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
|
||||
width: "auto", height: "auto", objectFit: "contain",
|
||||
pointerEvents: "none", userSelect: "none", webkitUserSelect: "none"
|
||||
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;
|
||||
};
|
||||
@@ -363,7 +372,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
Object.assign(scrollContainer.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
display: "flex", overflow: "auto", boxSizing: "border-box",
|
||||
padding: "54px 28px 48px 28px", scrollbarWidth: "thin",
|
||||
padding: "48px 24px", scrollbarWidth: "thin",
|
||||
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
|
||||
});
|
||||
overlay.container.appendChild(scrollContainer);
|
||||
@@ -386,22 +395,11 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
maxWidth: "85%", maxHeight: "85%", margin: "auto",
|
||||
flexShrink: "0", cursor: isBaseVideo ? "default" : "zoom-in"
|
||||
margin: "auto", flexShrink: "0",
|
||||
cursor: isBaseVideo ? "default" : "zoom-in"
|
||||
});
|
||||
scrollContainer.appendChild(wrapper);
|
||||
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
|
||||
zIndex: "30", color: "#888", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none", background: "rgba(10,10,10,0.75)", padding: "4px 10px",
|
||||
borderRadius: "4px", backdropFilter: "blur(4px)", boxShadow: "0 2px 6px rgba(0,0,0,0.4)"
|
||||
});
|
||||
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";
|
||||
overlay.container.appendChild(hintPrompt);
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
let isZoomed = false;
|
||||
@@ -448,6 +446,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
|
||||
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";
|
||||
@@ -460,13 +459,13 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
mediaA.style.height = "100%";
|
||||
mediaA.style.objectFit = "fill";
|
||||
} else {
|
||||
wrapper.style.maxWidth = "85%";
|
||||
wrapper.style.maxHeight = "85%";
|
||||
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 = "100%";
|
||||
mediaA.style.maxHeight = "80vh";
|
||||
mediaA.style.maxWidth = "calc(100vw - 120px)";
|
||||
mediaA.style.maxHeight = "calc(100vh - 120px)";
|
||||
mediaA.style.width = "auto";
|
||||
mediaA.style.height = "auto";
|
||||
mediaA.style.objectFit = "contain";
|
||||
@@ -501,8 +500,10 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
}
|
||||
|
||||
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 | Esc to close.";
|
||||
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);
|
||||
@@ -525,21 +526,19 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
wrapper.style.maxWidth = "85%";
|
||||
wrapper.style.maxHeight = "85%";
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.aspectRatio = `${maxW} / ${maxH}`;
|
||||
wrapper.style.cursor = "zoom-in";
|
||||
|
||||
[mediaA, mediaB].forEach(el => {
|
||||
if (el) {
|
||||
el.style.maxWidth = "100%";
|
||||
el.style.maxHeight = "80vh";
|
||||
el.style.width = "100%";
|
||||
el.style.height = "100%";
|
||||
el.style.objectFit = "fill";
|
||||
el.style.aspectRatio = `${maxW} / ${maxH}`;
|
||||
el.style.maxWidth = "calc(100vw - 120px)";
|
||||
el.style.maxHeight = "calc(100vh - 120px)";
|
||||
el.style.width = "auto";
|
||||
el.style.height = "auto";
|
||||
el.style.objectFit = "contain";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+16
-2
@@ -152,10 +152,24 @@ export function setupDragAndDrop() {
|
||||
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)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
+122
-12
@@ -17,7 +17,64 @@ export async function syncQueue() {
|
||||
const runningList = q.Running || q.queue_running || [];
|
||||
const pendingList = q.Pending || q.queue_pending || [];
|
||||
const pendingIds = new Set();
|
||||
const runningIds = new Set();
|
||||
|
||||
runningList.forEach(p => {
|
||||
let pid = null;
|
||||
let pWorkflow = null;
|
||||
let pOutputs = {};
|
||||
|
||||
if (Array.isArray(p)) {
|
||||
pid = p[1] ? String(p[1]) : null;
|
||||
const extraData = p[3] || {};
|
||||
pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
pOutputs = 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;
|
||||
const extraData = p.extra_data || {};
|
||||
pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
pOutputs = p.outputs || {};
|
||||
}
|
||||
|
||||
if (pid) {
|
||||
runningIds.add(pid);
|
||||
State.currentlyActivePromptId = pid;
|
||||
|
||||
// Restore active card if refreshed mid-generation
|
||||
if (!promptStates.has(pid)) {
|
||||
State.sequenceNumber++;
|
||||
const activeWorkflow = pWorkflow || app.graph?.serialize?.() || null;
|
||||
const images = getPrimaryOutputImages(pOutputs, activeWorkflow);
|
||||
const texts = findTextsInOutputs(pOutputs, activeWorkflow);
|
||||
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
status: "active",
|
||||
images: images,
|
||||
texts: texts,
|
||||
nodeOutputs: pOutputs,
|
||||
progress: 0,
|
||||
progressText: "Processing...",
|
||||
timestamp: State.sequenceNumber,
|
||||
workflow: activeWorkflow,
|
||||
startTime: Date.now(),
|
||||
duration: null
|
||||
});
|
||||
} else {
|
||||
const st = promptStates.get(pid);
|
||||
if (st.status !== "active") {
|
||||
st.status = "active";
|
||||
st.progressText = "Processing...";
|
||||
st.rendered = false;
|
||||
if (!st.startTime) st.startTime = Date.now();
|
||||
}
|
||||
if (!st.workflow && pWorkflow) {
|
||||
st.workflow = pWorkflow;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedPending = pendingList.map((p, idx) => {
|
||||
let pid = null, seq = idx;
|
||||
if (Array.isArray(p)) {
|
||||
@@ -55,10 +112,14 @@ export async function syncQueue() {
|
||||
});
|
||||
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
if (state.status === "pending" && !pendingIds.has(pid)) promptStates.delete(pid);
|
||||
if (state.status === "pending" && !pendingIds.has(pid)) {
|
||||
promptStates.delete(pid);
|
||||
} else if (state.status === "active" && !runningIds.has(pid)) {
|
||||
concludeRun(pid, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarBadgeFn(pendingIds.size + (runningList.length > 0 ? 1 : 0));
|
||||
updateSidebarBadgeFn(pendingIds.size + (runningIds.size > 0 ? 1 : 0));
|
||||
renderDOMFn();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to sync queue state", err);
|
||||
@@ -96,6 +157,19 @@ const concludeRun = async (pid, statusStr) => {
|
||||
st.workflow = parseWorkflow(rawWf) || st.workflow;
|
||||
st.nodeOutputs = hItem[key].outputs;
|
||||
|
||||
const serverStatus = hItem[key].status?.status_str;
|
||||
const isInterrupted = statusStr === "cancelled" ||
|
||||
serverStatus === "interrupted" ||
|
||||
(hItem[key].status?.messages && JSON.stringify(hItem[key].status.messages).toLowerCase().includes("interrupted"));
|
||||
|
||||
if (serverStatus === "success" || serverStatus === "completed") {
|
||||
st.status = "completed";
|
||||
} else if (isInterrupted) {
|
||||
st.status = "cancelled";
|
||||
} else if (serverStatus === "error") {
|
||||
st.status = "error";
|
||||
}
|
||||
|
||||
const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow);
|
||||
if (primaryImgs.length > 0) st.images = primaryImgs;
|
||||
|
||||
@@ -141,13 +215,13 @@ export function setupApiListeners() {
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
|
||||
st.status = "active"; st.progressText = "Processing..."; st.workflow = activeWorkspaceWorkflow;
|
||||
st.rendered = false; st.startTime = Date.now(); st.duration = null;
|
||||
} else {
|
||||
State.sequenceNumber++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid, status: "active", images: [], progress: 0,
|
||||
progressText: "Sampling...", timestamp: State.sequenceNumber,
|
||||
progressText: "Processing...", timestamp: State.sequenceNumber,
|
||||
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
|
||||
});
|
||||
}
|
||||
@@ -165,12 +239,14 @@ export function setupApiListeners() {
|
||||
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...";
|
||||
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Processing...";
|
||||
cardObj.statusText.textContent = `${nodeName} ${st.progress}%`;
|
||||
}
|
||||
} else {
|
||||
renderDOMFn();
|
||||
}
|
||||
} else if (pid && !promptStates.has(pid)) {
|
||||
syncQueue();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,12 +257,15 @@ export function setupApiListeners() {
|
||||
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
|
||||
const st = promptStates.get(State.currentlyActivePromptId);
|
||||
if (nodeId) {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
|
||||
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
|
||||
} else {
|
||||
st.activeNodeName = "Finishing...";
|
||||
}
|
||||
renderDOMFn();
|
||||
// Don't re-render if user is currently browsing an intermediate/batch submenu
|
||||
if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,7 +278,10 @@ export function setupApiListeners() {
|
||||
st._oldPreviewBlobUrl = st._previewBlobUrl;
|
||||
st._previewBlobUrl = newBlobUrl;
|
||||
st.images = [{ url: newBlobUrl }];
|
||||
renderDOMFn();
|
||||
// Keep preview updated in memory, but don't kick the user out of an active submenu
|
||||
if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,7 +323,6 @@ export function setupApiListeners() {
|
||||
api.addEventListener("execution_error", onExecutionError);
|
||||
api.addEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
|
||||
// Return cleanup function to unhook listeners when destroyed
|
||||
return () => {
|
||||
api.removeEventListener("status", onStatus);
|
||||
api.removeEventListener("reconnected", onReconnected);
|
||||
@@ -280,8 +361,6 @@ export async function initSessionAndHistory() {
|
||||
});
|
||||
|
||||
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 || {};
|
||||
@@ -290,11 +369,42 @@ export async function initSessionAndHistory() {
|
||||
const texts = findTextsInOutputs(outputs, workflow);
|
||||
|
||||
const statusStr = data.status?.status_str || "completed";
|
||||
const isInterrupted = statusStr === "interrupted" ||
|
||||
(data.status?.messages && JSON.stringify(data.status.messages).toLowerCase().includes("interrupted"));
|
||||
|
||||
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
|
||||
(statusStr === "error" ? "error" : "cancelled");
|
||||
(isInterrupted ? "cancelled" : (statusStr === "error" ? "error" : "cancelled"));
|
||||
|
||||
if (images.length === 0 && texts.length === 0 && status === "completed") return;
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
if (st.status === "active" || st.status === "pending") {
|
||||
st.status = status;
|
||||
st.images = images;
|
||||
st.texts = texts;
|
||||
st.nodeOutputs = outputs;
|
||||
st.workflow = workflow || st.workflow;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
} else {
|
||||
// Restore workflow & outputs from server history on startup
|
||||
if (!st.workflow && workflow) {
|
||||
st.workflow = workflow;
|
||||
}
|
||||
if ((!st.images || st.images.length === 0) && images.length > 0) {
|
||||
st.images = images;
|
||||
}
|
||||
if ((!st.nodeOutputs || Object.keys(st.nodeOutputs).length === 0) && outputs && Object.keys(outputs).length > 0) {
|
||||
st.nodeOutputs = outputs;
|
||||
}
|
||||
if ((!st.texts || st.texts.length === 0) && texts && texts.length > 0) {
|
||||
st.texts = texts;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
State.sequenceNumber++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
|
||||
+10
-2
@@ -165,8 +165,13 @@ function toggleIgnoreActiveNode() {
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
if (app.graph) {
|
||||
if (app.graph.change) app.graph.change(); // Marks workflow as modified so ComfyUI autosaves it
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
|
||||
renderDOM(); // Immediately update sidebar buttons without needing a reload
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
@@ -251,7 +256,10 @@ app.registerExtension({
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => syncAllNodeBadges());
|
||||
requestAnimationFrame(() => {
|
||||
syncAllNodeBadges();
|
||||
renderDOM();
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
|
||||
+159
-4
@@ -64,7 +64,6 @@ export function injectStyles() {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Hover Panels CSS Hover Control for Completed / Cancelled / Error cards */
|
||||
.comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
@@ -106,7 +105,88 @@ export function injectStyles() {
|
||||
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Header Icons & Header Action Buttons */
|
||||
/* 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);
|
||||
@@ -134,7 +214,6 @@ export function injectStyles() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scroll To Top Floating Button */
|
||||
.comfy-sidebar-scroll-top-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
@@ -333,7 +412,83 @@ export function injectStyles() {
|
||||
100% { transform: scaleY(1.3); }
|
||||
}
|
||||
|
||||
/* Text Output Card Clamping */
|
||||
/* 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;
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, cardElements, scheduleStateSave, deletePromptState } from "./state.js";
|
||||
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs, extractWorkflowFromPng } from "./utils.js";
|
||||
import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js";
|
||||
|
||||
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 let syncQueueFn = async () => {};
|
||||
export function setSyncQueue(fn) { syncQueueFn = fn; }
|
||||
|
||||
@@ -17,6 +39,65 @@ export function stopAllAudioPlayback() {
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
async function openFileOrFolder(img) {
|
||||
if (!img) return;
|
||||
|
||||
// 1. Try server-side open-folder endpoint if backend endpoint exists
|
||||
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) {}
|
||||
|
||||
// 2. Fallback: open / view / download the file
|
||||
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();
|
||||
}
|
||||
|
||||
function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardElements.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
@@ -59,6 +140,26 @@ const scrollListener = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeyDown = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") {
|
||||
document.body.classList.add("comfy-sidebar-ctrl-active");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeyUp = (e) => {
|
||||
if (e.key === "Control" || e.key === "Meta") {
|
||||
document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalBlur = () => {
|
||||
document.body.classList.remove("comfy-sidebar-ctrl-active");
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
window.addEventListener("keyup", handleGlobalKeyUp);
|
||||
window.addEventListener("blur", handleGlobalBlur);
|
||||
|
||||
export function setupScrollListener() {
|
||||
document.addEventListener("scroll", scrollListener, { capture: true, passive: true });
|
||||
return () => {
|
||||
@@ -339,6 +440,8 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
|
||||
const isCompleted = state.status === "completed";
|
||||
const hasRealImages = isCompleted && state.images && state.images.length > 0 && !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) {
|
||||
@@ -358,19 +461,51 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnCopy) {
|
||||
if (hasRealImages && currentImg) {
|
||||
cardObj.btnCopy.style.removeProperty("display");
|
||||
cardObj.btnCopy.style.display = "inline-flex";
|
||||
cardObj.btnCopy.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "#4ade80";
|
||||
setTimeout(() => {
|
||||
cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "";
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cardObj.btnCopy.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnJson) {
|
||||
if (state.workflow) {
|
||||
if (state.workflow || isPng) {
|
||||
cardObj.btnJson.style.removeProperty("display");
|
||||
cardObj.btnJson.style.display = "inline-flex";
|
||||
cardObj.btnJson.onclick = (ev) => {
|
||||
cardObj.btnJson.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const blob = new Blob([JSON.stringify(state.workflow, null, 2)], { type: "application/json" });
|
||||
let wf = state.workflow;
|
||||
if (!wf && currentImg) {
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
|
||||
wf = await extractWorkflowFromPng(src);
|
||||
if (wf) state.workflow = wf;
|
||||
}
|
||||
if (!wf) {
|
||||
alert("No workflow metadata found in this file.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workflow_${state.pid}.json`;
|
||||
a.download = `workflow_${state.pid || "comfy"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
};
|
||||
} else {
|
||||
cardObj.btnJson.style.setProperty("display", "none", "important");
|
||||
@@ -383,7 +518,6 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
}
|
||||
|
||||
if (cardObj.btnFocus) {
|
||||
const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null;
|
||||
const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null;
|
||||
if (nodeId) {
|
||||
cardObj.btnFocus.style.removeProperty("display");
|
||||
@@ -403,7 +537,23 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
|
||||
if (cardObj.leftHoverBtn) {
|
||||
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
|
||||
if (outputs.length > 1) {
|
||||
const validOutputs = outputs.filter(o => o.images && o.images.length > 0);
|
||||
|
||||
// Signatures of images already showing on the primary card
|
||||
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 ONLY if there are genuine distinct/extra outputs
|
||||
const hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0;
|
||||
|
||||
if (hasIntermediates) {
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
cardObj.leftHoverBtn.onclick = (ev) => {
|
||||
@@ -426,7 +576,7 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
|
||||
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(i.filename || i.url) && !isAudioFormat(i.filename || i.url)) || [];
|
||||
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 || '')}`)
|
||||
@@ -482,6 +632,10 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
|
||||
preview3D.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
};
|
||||
|
||||
@@ -660,6 +814,10 @@ function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
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]);
|
||||
};
|
||||
}
|
||||
@@ -682,6 +840,87 @@ function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
|
||||
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
|
||||
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
|
||||
|
||||
if (!previewFile) {
|
||||
wrapper.innerHTML = "";
|
||||
previewFile = document.createElement("div");
|
||||
previewFile.className = "comfy-sidebar-file-wrapper";
|
||||
|
||||
// Top Row: Right-aligned badge
|
||||
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);
|
||||
|
||||
// Center Area: Inline icon + file name
|
||||
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);
|
||||
|
||||
// Bottom Row: Folder location + open action
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
function renderCardImages(cardObj, state) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
@@ -701,6 +940,7 @@ function renderCardImages(cardObj, state) {
|
||||
const isVideo = isVideoFormat(src);
|
||||
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) {
|
||||
@@ -711,6 +951,14 @@ function renderCardImages(cardObj, state) {
|
||||
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;
|
||||
@@ -721,6 +969,11 @@ function renderCardImages(cardObj, 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"));
|
||||
|
||||
@@ -742,6 +995,10 @@ function renderCardImages(cardObj, state) {
|
||||
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([src], ev.shiftKey);
|
||||
};
|
||||
|
||||
@@ -756,10 +1013,7 @@ function renderCardImages(cardObj, state) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (cardObj.placeholder) {
|
||||
cardObj.placeholder.textContent = "Error loading media preview";
|
||||
cardObj.placeholder.style.display = "block";
|
||||
}
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1045,7 +1299,93 @@ export function renderDOM() {
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
|
||||
btnImg.title = "Download Object";
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
const btnDel = document.createElement("span");
|
||||
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
|
||||
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
|
||||
const btnDelLabel = document.createElement("span");
|
||||
btnDelLabel.className = "comfy-sidebar-del-label";
|
||||
btnDelLabel.textContent = "Delete File";
|
||||
btnDel.appendChild(btnDelLabel);
|
||||
|
||||
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
|
||||
const resetDelete = () => {
|
||||
isDeletePending = false;
|
||||
isDiskDelete = false;
|
||||
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
|
||||
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
|
||||
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
|
||||
};
|
||||
|
||||
btnDel.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
if (!isDeletePending) {
|
||||
isDeletePending = true;
|
||||
isDiskDelete = wantsDiskDelete;
|
||||
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
|
||||
btnDel.title = wantsDiskDelete
|
||||
? "Ctrl+Click again to delete file from DISK / TRASH"
|
||||
: "Click again to confirm removing from batch";
|
||||
deleteTimeout = setTimeout(resetDelete, 2000);
|
||||
} else {
|
||||
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
|
||||
resetDelete();
|
||||
|
||||
// 1. Delete only this single file from disk
|
||||
if (shouldDeleteFromDisk && img && img.filename) {
|
||||
try {
|
||||
await fetch("/comfy-sidebar/delete-file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: img.filename,
|
||||
subfolder: img.subfolder || "",
|
||||
type: img.type || "output"
|
||||
})
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 2. Remove this image from batch list, prompt state, and nodeOutputs
|
||||
const pid = batchInfo.pid;
|
||||
const state = promptStates.get(pid);
|
||||
const imgIdx = batchInfo.images.indexOf(img);
|
||||
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
|
||||
|
||||
if (state) {
|
||||
if (Array.isArray(state.images)) {
|
||||
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
|
||||
if (stIdx > -1) state.images.splice(stIdx, 1);
|
||||
}
|
||||
|
||||
// Sync nodeOutputs so the Intermediate Outputs submenu stays updated
|
||||
if (state.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(state.nodeOutputs, img);
|
||||
}
|
||||
|
||||
state.rendered = false;
|
||||
}
|
||||
|
||||
// Invalidate main card's cached index & signature
|
||||
const mainCardObj = cardElements.get(pid);
|
||||
if (mainCardObj) {
|
||||
mainCardObj.currentImageIndex = 0;
|
||||
mainCardObj.lastImagesSignature = "";
|
||||
}
|
||||
|
||||
// If batch is now empty, return to main queue
|
||||
if (batchInfo.images.length === 0) {
|
||||
State.activeSubmenuBatchImages = null;
|
||||
}
|
||||
|
||||
scheduleStateSave();
|
||||
renderDOM();
|
||||
}
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnDel);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
@@ -1108,7 +1448,23 @@ export function renderDOM() {
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const outputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
const seenFiles = new Set();
|
||||
const outputs = [];
|
||||
|
||||
// Deduplicate across nodes so two nodes producing the exact same image don't duplicate cards
|
||||
rawOutputs.forEach(out => {
|
||||
const uniqueImgs = (out.images || []).filter(img => {
|
||||
const key = `${img.subfolder || ""}/${img.filename}`;
|
||||
if (seenFiles.has(key)) return false;
|
||||
seenFiles.add(key);
|
||||
return true;
|
||||
});
|
||||
if (uniqueImgs.length > 0) {
|
||||
outputs.push({ nodeId: out.nodeId, images: uniqueImgs });
|
||||
}
|
||||
});
|
||||
|
||||
const targetElements = [];
|
||||
|
||||
outputs.forEach((out) => {
|
||||
@@ -1151,7 +1507,79 @@ export function renderDOM() {
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
|
||||
btnImg.title = "Download Object";
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
const btnDel = document.createElement("span");
|
||||
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
|
||||
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
|
||||
const btnDelLabel = document.createElement("span");
|
||||
btnDelLabel.className = "comfy-sidebar-del-label";
|
||||
btnDelLabel.textContent = "Delete File";
|
||||
btnDel.appendChild(btnDelLabel);
|
||||
|
||||
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
|
||||
const resetDelete = () => {
|
||||
isDeletePending = false;
|
||||
isDiskDelete = false;
|
||||
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
|
||||
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
|
||||
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
|
||||
};
|
||||
|
||||
btnDel.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
if (!isDeletePending) {
|
||||
isDeletePending = true;
|
||||
isDiskDelete = wantsDiskDelete;
|
||||
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
|
||||
btnDel.title = wantsDiskDelete
|
||||
? "Ctrl+Click again to delete this node's files from DISK / TRASH"
|
||||
: "Click again to confirm removing output";
|
||||
deleteTimeout = setTimeout(resetDelete, 2000);
|
||||
} else {
|
||||
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
|
||||
resetDelete();
|
||||
|
||||
// 1. Delete files belonging only to this specific node
|
||||
if (shouldDeleteFromDisk && out.images) {
|
||||
for (const imgItem of out.images) {
|
||||
if (imgItem && imgItem.filename) {
|
||||
try {
|
||||
await fetch("/comfy-sidebar/delete-file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: imgItem.filename,
|
||||
subfolder: imgItem.subfolder || "",
|
||||
type: imgItem.type || "output"
|
||||
})
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove node from state.nodeOutputs
|
||||
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
|
||||
delete st.nodeOutputs[out.nodeId];
|
||||
}
|
||||
|
||||
// 3. Update primary images if needed
|
||||
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
if (remainingOutputs.length > 0) {
|
||||
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
|
||||
} else {
|
||||
st.images = [];
|
||||
State.activeSubmenuPromptId = null;
|
||||
}
|
||||
|
||||
scheduleStateSave();
|
||||
renderDOM();
|
||||
}
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnDel);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
@@ -1188,8 +1616,6 @@ export function renderDOM() {
|
||||
targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
|
||||
while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
|
||||
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) scrollEl.scrollTop = 0;
|
||||
updateScrollTopBtnVisibility();
|
||||
return;
|
||||
}
|
||||
@@ -1215,6 +1641,20 @@ export function renderDOM() {
|
||||
let cardObj = cardElements.get(state.pid);
|
||||
const isFinalStatus = state.status === "completed" || state.status === "cancelled" || state.status === "error";
|
||||
if (cardObj && isFinalStatus && state.rendered) {
|
||||
// Ensure image changes (e.g. deletions from batch submenu) update the preview immediately
|
||||
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
|
||||
if (cardObj.lastImagesSignature !== currentImagesSignature) {
|
||||
cardObj.currentImageIndex = Math.min(cardObj.currentImageIndex || 0, Math.max(0, (state.images?.length || 1) - 1));
|
||||
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);
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
syncCardButtonVisibility(cardObj, state);
|
||||
return cardObj.element;
|
||||
}
|
||||
@@ -1251,8 +1691,19 @@ export function renderDOM() {
|
||||
});
|
||||
|
||||
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 Image to Clipboard";
|
||||
|
||||
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"; btnDel.title = "Delete Card";
|
||||
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";
|
||||
@@ -1270,11 +1721,11 @@ export function renderDOM() {
|
||||
leftHoverBtn.title = "View all intermediate outputs";
|
||||
|
||||
hoverPanel.append(btnImg, btnJson, btnDel);
|
||||
leftHoverPanel.append(btnFocus, leftHoverBtn);
|
||||
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
|
||||
|
||||
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnCopy, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
syncCardButtonVisibility(cardObj, state);
|
||||
@@ -1345,23 +1796,69 @@ export function renderDOM() {
|
||||
cardObj.cancelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
let deleteTimeout = null, isDeletePending = false;
|
||||
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
|
||||
const resetDeleteBtn = () => {
|
||||
isDeletePending = false;
|
||||
cardObj.btnDel.classList.remove("confirm-delete");
|
||||
cardObj.btnDel.title = "Delete Card";
|
||||
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; }
|
||||
};
|
||||
|
||||
cardObj.btnDel.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
if (!isDeletePending) {
|
||||
isDeletePending = true;
|
||||
cardObj.btnDel.classList.add("confirm-delete");
|
||||
cardObj.btnDel.title = "Click again to confirm deletion";
|
||||
deleteTimeout = setTimeout(resetDeleteBtn, 1500);
|
||||
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();
|
||||
|
||||
// Delete all files from disk across ALL nodes in this run
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Primary card images/files
|
||||
(state.images || []).forEach(addFile);
|
||||
|
||||
// 2. All intermediate node outputs
|
||||
const allOutputs = getRunOutputs(state.nodeOutputs, state.workflow);
|
||||
allOutputs.forEach(out => (out.images || []).forEach(addFile));
|
||||
|
||||
// 3. Delete each file from disk / trash
|
||||
for (const fileItem of filesToDelete) {
|
||||
try {
|
||||
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"
|
||||
})
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
promptStates.delete(state.pid);
|
||||
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
|
||||
scheduleStateSave();
|
||||
@@ -1386,6 +1883,7 @@ export function renderDOM() {
|
||||
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();
|
||||
@@ -1406,7 +1904,7 @@ export function renderDOM() {
|
||||
if (state.status === "active") {
|
||||
if (showWorkingNode) {
|
||||
cardObj.statusText.style.display = "block";
|
||||
cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Sampling...${state.progress ? ` ${state.progress}%` : ""}`;
|
||||
cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Processing...${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"; }
|
||||
|
||||
+194
-31
@@ -1,25 +1,37 @@
|
||||
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 const isImageFormat = (url) => {
|
||||
if (!url) return false;
|
||||
if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true;
|
||||
const s = String(url).toLowerCase().split("?")[0];
|
||||
return s.endsWith(".png") || s.endsWith(".jpg") || s.endsWith(".jpeg") ||
|
||||
s.endsWith(".webp") || s.endsWith(".gif") || s.endsWith(".bmp") ||
|
||||
s.endsWith(".tiff") || s.endsWith(".tif") || s.endsWith(".svg") ||
|
||||
s.endsWith(".avif") || s.endsWith(".ico") || s.endsWith(".apng");
|
||||
};
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
const s = String(url).toLowerCase().split("?")[0];
|
||||
return s.endsWith(".mp4") || s.endsWith(".webm") || s.endsWith(".mov") || s.endsWith(".mkv");
|
||||
};
|
||||
|
||||
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");
|
||||
const s = String(url).toLowerCase().split("?")[0];
|
||||
return s.endsWith(".glb") || s.endsWith(".gltf") || s.endsWith(".obj") ||
|
||||
s.endsWith(".ply") || s.endsWith(".stl") || s.endsWith(".splat") ||
|
||||
s.endsWith(".spz") || s.endsWith(".ksplat") || s.endsWith(".fbx");
|
||||
};
|
||||
|
||||
export const isAudioFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".wav") || s.includes(".mp3") || s.includes(".ogg") ||
|
||||
s.includes(".flac") || s.includes(".m4a") || s.includes(".aac") ||
|
||||
s.includes(".opus");
|
||||
const s = String(url).toLowerCase().split("?")[0];
|
||||
return s.endsWith(".wav") || s.endsWith(".mp3") || s.endsWith(".ogg") ||
|
||||
s.endsWith(".flac") || s.endsWith(".m4a") || s.endsWith(".aac") ||
|
||||
s.endsWith(".opus");
|
||||
};
|
||||
|
||||
export function getFilenameFromUrl(url) {
|
||||
@@ -46,6 +58,14 @@ export function parseWorkflow(workflow) {
|
||||
}
|
||||
|
||||
function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
// 1. Check live canvas node property first
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
// 2. Fallback to historical workflow snapshot
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (!workflow || !Array.isArray(workflow.nodes)) {
|
||||
return false;
|
||||
@@ -54,37 +74,135 @@ function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
return !!(node && node.properties && node.properties.ignoreInQueue);
|
||||
}
|
||||
|
||||
function extractFileCandidate(val, subfolder = "", type = "output") {
|
||||
if (!val || typeof val !== "string") return null;
|
||||
const trimmed = val.trim();
|
||||
|
||||
// 1. Ignore directory dots, floats/numbers, and empty strings
|
||||
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
if (!isNaN(Number(trimmed))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Extract base file name
|
||||
const cleanName = trimmed.split("/").pop()?.split("\\").pop()?.trim() || "";
|
||||
if (!cleanName || cleanName === "." || cleanName === "..") return null;
|
||||
|
||||
// 3. Must have a dot with real name before and after (e.g. "model.safetensors")
|
||||
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;
|
||||
|
||||
// 4. Must match a known extension or a valid 1-8 char alphanumeric extension without whitespace
|
||||
const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100);
|
||||
|
||||
if (isValidExt) {
|
||||
return {
|
||||
filename: cleanName,
|
||||
subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""),
|
||||
type: type || "output"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findImagesInOutputs(outputs, rawWorkflow) {
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
const seen = new Set();
|
||||
|
||||
const scan = (obj) => {
|
||||
const addCandidate = (item) => {
|
||||
if (!item || !item.filename) return;
|
||||
const key = `${item.subfolder || ""}/${item.filename}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
list.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const scan = (obj, sub = "", typ = "output") => {
|
||||
if (!obj) return;
|
||||
if (typeof obj === "string") {
|
||||
const cand = extractFileCandidate(obj, sub, typ);
|
||||
if (cand) addCandidate(cand);
|
||||
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"
|
||||
});
|
||||
obj.forEach(item => scan(item, sub, typ));
|
||||
return;
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const targetSub = 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;
|
||||
|
||||
if (fname && typeof fname === "string") {
|
||||
const cand = extractFileCandidate(fname, targetSub, targetTyp);
|
||||
if (cand) {
|
||||
addCandidate(cand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
scan(obj[key]);
|
||||
scan(obj[key], targetSub, targetTyp);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
scan(outputs[nodeId]);
|
||||
|
||||
if (outputs) {
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
scan(outputs[nodeId]);
|
||||
}
|
||||
}
|
||||
|
||||
if (list.length > 0) return list;
|
||||
|
||||
// Fallback: If scanning a specific node ID, only look at that matching node in the workflow
|
||||
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.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 fname = val.trim();
|
||||
if (typeStr.includes("lora") && !fname.includes(".")) {
|
||||
fname += ".safetensors";
|
||||
} else if (typeStr.includes("latent") && !fname.includes(".")) {
|
||||
fname += ".latent";
|
||||
}
|
||||
const cand = extractFileCandidate(fname, typeStr.includes("lora") ? "loras" : "", "output");
|
||||
if (cand) addCandidate(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -98,14 +216,14 @@ export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
const val = outputs[nodeId][key];
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach(item => {
|
||||
if (typeof item === 'string') {
|
||||
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') {
|
||||
} 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);
|
||||
@@ -121,7 +239,12 @@ export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
|
||||
const nodeOut = nodeOutputs[nodeId];
|
||||
// Ignore empty node outputs
|
||||
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
|
||||
continue;
|
||||
}
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
|
||||
if (imgs.length > 0) {
|
||||
list.push({ nodeId, images: imgs });
|
||||
}
|
||||
@@ -130,9 +253,9 @@ export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
}
|
||||
|
||||
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return [];
|
||||
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return [];
|
||||
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
|
||||
return runOutputs[runOutputs.length - 1].images || [];
|
||||
}
|
||||
|
||||
@@ -160,4 +283,44 @@ export function matchesFilter(state, query) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function extractWorkflowFromPng(imageUrl) {
|
||||
try {
|
||||
const res = await fetch(imageUrl);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Verify PNG signature (89 50 4E 47 0D 0A 1A 0A)
|
||||
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; // 4 (length) + 4 (type) + length (data) + 4 (crc)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Comfy Sidebar: Could not extract workflow from PNG", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.3.2"
|
||||
version = "1.4.2"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user