Compare commits

...
10 Commits
Author SHA1 Message Date
m0rtus59 b93413a729 fixes and improvements 2026-08-28 23:02:52 +05:00
m0rtus59 782d30b490 add ctrl+click for alt actions on cards 2026-08-28 22:02:42 +05:00
m0rtus59 0a2db6b85f add support for misc files 2026-08-28 21:32:46 +05:00
m0rtus59 5a4c9f99a5 improve preview, drag-drop, and queue handling 2026-08-28 20:38:46 +05:00
m0rtus59 141a0872d5 minor fixes 2026-08-22 18:12:03 +05:00
m0rtus59 108ac61bec bump 2026-08-22 17:50:24 +05:00
m0rtus59 b31cfb5f8a remove duplicate tip 2026-08-22 15:48:22 +05:00
M0rtus59 fe154b7787 Refactor overlay system, debounce state saving, and fix pending card controls
- Unified Overlay System: Reusable SidebarOverlay for all viewers.

- Performance: Debounced localStorage persistence to prevent UI stutters.

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

- UI Fix: Pending/active cards only show cancel button on hover.
2026-08-14 23:40:07 +05:00
M0rtus59 da5756d61c Add support for 3D, Audio-only, and text-only outputs 2026-08-14 22:40:00 +05:00
M0rtus59 370f4fc58c Preview and compare menu improvements 2026-08-14 20:13:20 +05:00
15 changed files with 3124 additions and 491 deletions
+4 -1
View File
@@ -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
+174 -4
View File
@@ -1,8 +1,178 @@
# 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):
subprocess.Popen(["explorer", "/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])
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()
filename = data.get("filename", "")
subfolder = data.get("subfolder", "")
folder_type = data.get("type", "output")
target_file, root = resolve_existing_path(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)
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)
+511 -177
View File
@@ -1,16 +1,228 @@
import { isVideoFormat, is3DFormat } from "./utils.js";
import { app } from "/scripts/app.js";
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js";
import { create3DViewer } from "./viewer3d.js";
import { createTextReader } from "./text_reader.js";
import { stopAllAudioPlayback } from "./ui.js";
import { SidebarOverlay } from "./overlay.js";
let activeComparisonViewer = null;
let globalKeydownHandler = null;
// Safe clientX resolver for mouse and touch events
export function isAudioViewerOpen() {
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
}
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");
@@ -22,8 +234,16 @@ const createMediaElement = (src, muted = false) => {
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
objectFit: "contain", pointerEvents: "none"
gridArea: "1 / 1",
maxWidth: "calc(100vw - 120px)",
maxHeight: "calc(100vh - 120px)",
width: "auto",
height: "auto",
objectFit: "contain",
pointerEvents: "none",
userSelect: "none",
webkitUserSelect: "none",
display: "block"
});
el.src = isVideo ? src + "#t=0.001" : src;
return el;
@@ -35,9 +255,7 @@ const setupVideoPlayback = (vid, container) => {
let videoSyncActive = true;
let syncAnimationFrameId = null;
const onMetaLoaded = () => {
vid.play().catch(()=>{});
};
const onMetaLoaded = () => { vid.play().catch(()=>{}); };
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
@@ -72,9 +290,7 @@ const setupVideoPlayback = (vid, container) => {
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, {
width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px"
});
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px" });
scrubberContainer.appendChild(scrubberFill);
const timeLabel = document.createElement("span");
@@ -107,7 +323,6 @@ const setupVideoPlayback = (vid, container) => {
const syncLoop = () => {
if (!videoSyncActive) return;
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
@@ -119,7 +334,6 @@ const setupVideoPlayback = (vid, container) => {
};
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
@@ -137,89 +351,56 @@ const setupVideoPlayback = (vid, container) => {
};
};
function createComparisonViewer(baseSrc) {
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const isBaseVideo = isVideoFormat(baseSrc);
const canvasEl = document.querySelector("#graph-canvas, canvas");
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
let splitRatio = 50;
let mediaB = null;
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 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", display: "flex", gap: "16px",
zIndex: "30", color: "#aaa", fontSize: "12px", fontFamily: "sans-serif",
pointerEvents: "none"
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30", color: "#aaa", 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)", maxWidth: "85%", textAlign: "center"
});
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.";
: "Click image to zoom (100%/Fit) | Shift+Click another card 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);
overlay.container.appendChild(header);
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"
});
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();
}
};
scrollContainer.appendChild(wrapper);
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
let mediaB = null;
let isZoomed = false;
const slider = document.createElement("div");
Object.assign(slider.style, {
@@ -234,17 +415,12 @@ function createComparisonViewer(baseSrc) {
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"
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);
let splitRatio = 50;
let isDragging = false;
let destroyVideoPlaybackFn = null;
const updateSliderPosition = (percent) => {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
@@ -253,37 +429,6 @@ function createComparisonViewer(baseSrc) {
}
};
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();
@@ -292,72 +437,212 @@ function createComparisonViewer(baseSrc) {
}
};
const destroy = () => {
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 (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";
infoText.style.color = "#aaa";
infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card | 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);
if (globalKeydownHandler) {
document.removeEventListener("keydown", globalKeydownHandler);
globalKeydownHandler = null;
}
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
});
cleanupMedia(mediaA);
cleanupMedia(mediaB);
wrapper.addEventListener("click", () => {
if (dragMoved || isBaseVideo) return;
toggleZoom();
});
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);
scrollContainer.addEventListener("click", (e) => {
if (dragMoved) return;
if (e.target === scrollContainer || e.target === overlay.container) overlay.destroy();
});
if (isBaseVideo) {
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
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) {
destroy();
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
overlay.destroy();
showFullscreenPreview([targetSrc], isShiftClick);
return;
}
if (isTargetVideo) {
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);
slider.style.display = "block";
infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close.";
updateSliderPosition(50);
if (mediaB.complete && mediaB.naturalWidth) syncImageScales();
} else {
if (mediaB) {
cleanupMedia(mediaB);
@@ -366,42 +651,91 @@ function createComparisonViewer(baseSrc) {
}
slider.style.display = "none";
mediaA.src = targetSrc;
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
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) {
if (!imgSrcs || imgSrcs.length === 0) return;
const src = imgSrcs[0];
const item = 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);
// 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) {
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
activeComparisonViewer.destroy();
activeComparisonViewer = null;
} else {
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
}
activeComparisonViewer = createComparisonViewer(src);
activeComparisonViewer = createComparisonViewer(
src,
() => { activeComparisonViewer = null; }
);
}
+143 -94
View File
@@ -1,7 +1,21 @@
import { app } from "/scripts/app.js";
import { State } from "./state.js";
async function uploadDroppedImageToInput(imageObj) {
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);
@@ -22,13 +36,114 @@ async function uploadDroppedImageToInput(imageObj) {
}
}
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() {
document.addEventListener("dragover", (e) => {
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
};
document.addEventListener("drop", async (e) => {
const onDrop = async (e) => {
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
if (isSidebarDrop) {
@@ -37,100 +152,34 @@ export function setupDragAndDrop() {
return;
}
const canvas = app.canvas;
if (!canvas || !canvas.graph) return;
// 1. Check for workflow JSON drop (text-only and unfinished cards)
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
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) {}
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;
}
// 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") || "";
// Synchronously stop ComfyUI core drop listeners from double-handling
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
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);
for (const handler of dropHandlers) {
if (await handler(e)) {
return;
}
}
}, true);
};
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop, true);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop, true);
};
}
+13 -21
View File
@@ -3,7 +3,6 @@ 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,
@@ -15,7 +14,6 @@ div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
box-shadow: none !important;
}
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
@@ -25,7 +23,6 @@ div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
left: auto !important;
transform: none !important;
z-index: 1010 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
@@ -35,7 +32,6 @@ div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
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;
@@ -49,7 +45,6 @@ div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
align-items: center !important;
}
/* Scale down performance indicators (Crystools) uniformly */
#crysmonitor-monitors-root,
#crystools-monitors-root,
.crysmonitor-monitors-container {
@@ -58,7 +53,6 @@ div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
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"]),
@@ -74,14 +68,12 @@ button:has([data-pc-name="avatar"]),
display: none !important;
}
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
.p-tabview-nav-content,
[class*="tabview"] {
padding-left: 150px !important;
padding-right: 580px !important;
}
/* Medium desktop screens (between 1200px and 1599px width) */
@media (max-width: 1599px) {
.p-tabview-nav-content,
[class*="tabview"] {
@@ -90,7 +82,6 @@ button:has([data-pc-name="avatar"]),
}
}
/* Standard laptops / small monitors (between 900px and 1199px width) */
@media (max-width: 1199px) {
.p-tabview-nav-content,
[class*="tabview"] {
@@ -99,7 +90,6 @@ button:has([data-pc-name="avatar"]),
}
}
/* Very narrow or heavily zoomed displays (< 900px width) */
@media (max-width: 899px) {
.p-tabview-nav-content,
[class*="tabview"] {
@@ -109,16 +99,12 @@ button:has([data-pc-name="avatar"]),
}
`;
/**
* 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) {}
@@ -210,15 +196,11 @@ function findTopbarContainer() {
function isPropertiesPanelOpen() {
const panel = document.querySelector('[data-testid="properties-panel"]');
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) {
return true;
}
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;
}
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
}
const origBtn = findOriginalButton();
@@ -230,7 +212,6 @@ function isPropertiesPanelOpen() {
return true;
}
}
return false;
}
@@ -556,4 +537,15 @@ export function setupPropertiesPanelToggleFix() {
});
syncClassicLayout();
}
export function destroyLayoutFix() {
if (domObserver) {
domObserver.disconnect();
domObserver = null;
}
const styleEl = document.getElementById(STYLE_ID);
if (styleEl) styleEl.remove();
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
if (fixStyles) fixStyles.remove();
}
+101
View File
@@ -0,0 +1,101 @@
import { State } from "./state.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 || "rgba(10, 10, 10, 0.95)",
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: "20px", color: "#aaa", transition: "color 0.15s ease",
background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px"
});
this.closeBtn.onmouseenter = () => this.closeBtn.style.color = "#fff";
this.closeBtn.onmouseleave = () => this.closeBtn.style.color = "#aaa";
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 = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.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 = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.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();
}
}
+151 -38
View File
@@ -1,6 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } from "./state.js";
import { State, promptStates, pruneHistory, cardElements, scheduleStateSave } from "./state.js";
import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js";
export let renderDOMFn = () => {};
@@ -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)) {
@@ -38,10 +95,10 @@ export async function syncQueue() {
if (pid) {
pendingIds.add(pid);
if (!promptStates.has(pid)) {
State.globalOrderCounter++;
State.sequenceNumber++;
promptStates.set(pid, {
pid: pid, status: "pending", images: [], progress: 0, queueNumber: number,
progressText: `Pending... (#${number})`, timestamp: State.globalOrderCounter,
progressText: `Pending... (#${number})`, timestamp: State.sequenceNumber,
workflow: app.graph.serialize()
});
} else {
@@ -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;
@@ -108,20 +182,19 @@ const concludeRun = async (pid, statusStr) => {
}
pruneHistory(app);
saveStatesToLocalStorage();
scheduleStateSave();
syncQueue();
};
export function setupApiListeners() {
api.addEventListener("status", syncQueue);
api.addEventListener("reconnected", async () => {
const onStatus = () => syncQueue();
const onReconnected = async () => {
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
await initSessionAndHistory();
await syncQueue();
});
api.addEventListener("execution_start", (e) => {
};
const onExecutionStart = (e) => {
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
const toDelete = [];
for (const [p, s] of promptStates.entries()) {
@@ -132,7 +205,7 @@ export function setupApiListeners() {
}
if (toDelete.length > 0) {
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
saveStatesToLocalStorage();
scheduleStateSave();
}
}
@@ -142,21 +215,21 @@ 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.globalOrderCounter++;
State.sequenceNumber++;
promptStates.set(pid, {
pid: pid, status: "active", images: [], progress: 0,
progressText: "Sampling...", timestamp: State.globalOrderCounter,
progressText: "Processing...", timestamp: State.sequenceNumber,
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
renderDOMFn();
syncQueue();
});
};
api.addEventListener("progress", (e) => {
const onProgress = (e) => {
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
if (pid && promptStates.has(pid)) {
const st = promptStates.get(pid);
@@ -166,32 +239,34 @@ 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();
}
});
};
api.addEventListener("executing", (e) => {
const onExecuting = (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);
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
} else {
st.activeNodeName = "Finishing...";
}
renderDOMFn();
}
});
};
api.addEventListener("b_preview", (e) => {
const onBPreview = (e) => {
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
if (activeTasks.length > 0) {
const st = activeTasks[0];
@@ -202,9 +277,9 @@ export function setupApiListeners() {
st.images = [{ url: newBlobUrl }];
renderDOMFn();
}
});
};
api.addEventListener("executed", (e) => {
const onExecuted = (e) => {
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
if (pid && promptStates.has(pid)) {
const st = promptStates.get(pid);
@@ -222,14 +297,38 @@ export function setupApiListeners() {
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", () => {
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, "completed");
const onExecutionError = (e) => concludeRun(e.detail.prompt_id, "error");
const onExecutionInterrupted = () => {
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "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);
};
}
export async function initSessionAndHistory() {
@@ -256,8 +355,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 || {};
@@ -266,13 +363,29 @@ 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"));
// 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++;
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;
}
return;
}
State.sequenceNumber++;
promptStates.set(pid, {
pid: pid,
status: status,
@@ -281,13 +394,13 @@ export async function initSessionAndHistory() {
nodeOutputs: outputs,
workflow: workflow,
progressText: "",
timestamp: State.globalOrderCounter,
timestamp: State.sequenceNumber,
rendered: true
});
});
pruneHistory(app);
saveStatesToLocalStorage();
scheduleStateSave();
await syncQueue();
} catch (err) {
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
+13 -14
View File
@@ -1,15 +1,15 @@
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 { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM, setupScrollListener } from "./ui.js";
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings } from "./layout.js";
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./layout.js";
let isInitialized = false;
let activeKeydownHandler = null;
let nodeDOMObserver = null;
let cleanupFns = [];
// 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();
@@ -26,7 +26,6 @@ function findHeaderByText(parent, text) {
}
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}"], ` +
@@ -175,7 +174,6 @@ app.registerExtension({
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 });
@@ -275,24 +273,23 @@ app.registerExtension({
setUIDependencies(renderDOM, updateSidebarBadge);
injectStyles();
setupDragAndDrop();
// Register disposable subsystems
cleanupFns.push(setupDragAndDrop());
cleanupFns.push(setupScrollListener());
cleanupFns.push(setupApiListeners());
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 && (
@@ -302,7 +299,6 @@ app.registerExtension({
activeEl.tagName === "SELECT"
)) return;
// Toggle sidebar with Q
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
@@ -310,7 +306,6 @@ app.registerExtension({
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();
@@ -320,7 +315,6 @@ app.registerExtension({
document.addEventListener("keydown", activeKeydownHandler, true);
// Apply sidebar override when initializing
applySidebarOverride();
app.extensionManager.registerSidebarTab({
@@ -334,6 +328,11 @@ app.registerExtension({
},
destroy() {
for (const fn of cleanupFns.splice(0)) {
try { if (typeof fn === "function") fn(); } catch (e) {}
}
destroyLayoutFix();
if (nodeDOMObserver) {
nodeDOMObserver.disconnect();
nodeDOMObserver = null;
+16 -9
View File
@@ -3,15 +3,24 @@ export const cardElements = new Map();
export const State = {
currentSearchQuery: "",
globalOrderCounter: 0,
sequenceNumber: 0,
sidebarContainer: null,
cardStack: null, // Main queue scroll container
submenuStack: null, // Submenu scroll container
cardStack: null,
submenuStack: null,
currentlyActivePromptId: null,
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
activeSubmenuBatchImages: null // Active batch images explorer pointer
activeSubmenuPromptId: null,
activeSubmenuBatchImages: null
};
let saveTimer = null;
export function scheduleStateSave(delay = 250) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
saveStatesToLocalStorage();
}, delay);
}
export function deletePromptState(pid) {
const key = String(pid);
const state = promptStates.get(key);
@@ -32,7 +41,7 @@ export function pruneHistory(app) {
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);
tasks.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
if (tasks.length > maxItems) {
const deleteCount = tasks.length - maxItems;
@@ -52,8 +61,6 @@ export function saveStatesToLocalStorage() {
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({
@@ -105,7 +112,7 @@ export function loadStatesFromLocalStorage() {
list.forEach(state => {
const key = String(state.pid);
promptStates.set(key, state);
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
if (state.timestamp > State.sequenceNumber) State.sequenceNumber = state.timestamp;
});
}
} catch (e) {}
+273 -10
View File
@@ -29,6 +29,14 @@ export function injectStyles() {
.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;
@@ -56,14 +64,13 @@ export function injectStyles() {
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 {
.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;
}
@@ -72,11 +79,11 @@ export function injectStyles() {
display: inline-flex;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
width: 30px !important;
height: 30px !important;
background-color: rgba(0, 0, 0, 0.75) !important;
color: #e2e8f0 !important;
font-size: 14px !important;
font-size: 13px !important;
border-radius: 6px !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
@@ -98,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);
@@ -126,11 +214,10 @@ export function injectStyles() {
opacity: 1;
}
/* Scroll To Top Floating Button */
.comfy-sidebar-scroll-top-btn {
position: absolute;
bottom: 12px;
right: 12px;
bottom: 14px;
right: 28px;
width: 32px;
height: 32px;
border-radius: 50%;
@@ -249,6 +336,182 @@ export function injectStyles() {
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);
}
+329
View File
@@ -0,0 +1,329 @@
import { SidebarOverlay } from "./overlay.js";
// Global helper for code block copying in Markdown view
window.moonCopyCode = function(button) {
try {
const container = button.parentElement;
if (!container) return;
const codeEl = container.querySelector("code");
if (!codeEl) return;
const text = codeEl.innerText;
navigator.clipboard.writeText(text).then(() => {
button.innerText = "Copied!";
button.style.backgroundColor = "#4CAF50";
button.style.borderColor = "#45a049";
button.style.color = "#ffffff";
setTimeout(() => {
button.innerText = "Copy";
button.style.backgroundColor = "#333333";
button.style.borderColor = "#555555";
button.style.color = "#aaaaaa";
}, 1500);
}).catch(() => {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
});
} catch (e) {
button.innerText = "Error";
setTimeout(() => button.innerText = "Copy", 1500);
}
};
function parseInlineMarkdown(html) {
if (!html) return "";
html = html.replace(/`([^`]+)`/g, '<code style="background:#252525;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e06c75;border:1px solid #333;font-size:12px;">$1</code>');
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong style="color:#ffffff;font-weight:600;"><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
html = html.replace(/~~(.*?)~~/g, '<span style="text-decoration:line-through;color:#888;">$1</span>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#61afef;text-decoration:none;border-bottom:1px dashed #61afef;cursor:pointer;">$1</a>');
html = html.replace(/\$(?!\s)([^\$]+?)(?<!\s)\$/g, '<span style="font-family:\'Times New Roman\',Times,serif;font-size:14px;color:#e5c07b;font-style:italic;padding:0 2px;">$1</span>');
return html;
}
function parseMarkdown(text) {
if (!text) return "";
let rawLines = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").split("\n");
let htmlLines = [];
let inCodeBlock = false, codeContent = [], codeLang = "";
let inUl = false, inOl = false, inTable = false;
let tableHeaders = [], tableAlignments = [], tableRows = [];
function closeUl() { if (inUl) { htmlLines.push("</ul>"); inUl = false; } }
function closeOl() { if (inOl) { htmlLines.push("</ol>"); inOl = false; } }
function closeTable() {
if (inTable) {
let tableHtml = `<table style="border-collapse:collapse;width:100%;margin:12px 0;font-size:12px;border:1px solid #333;color:#ddd;text-align:left;">`;
if (tableHeaders.length > 0) {
tableHtml += `<thead style="background:#1a1a1a;font-weight:600;color:#fff;border-bottom:2px solid #444;"><tr>`;
for (let i = 0; i < tableHeaders.length; i++) {
const align = tableAlignments[i] || 'left';
tableHtml += `<th style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(tableHeaders[i])}</th>`;
}
tableHtml += `</tr></thead>`;
}
tableHtml += "<tbody>";
for (let r = 0; r < tableRows.length; r++) {
const row = tableRows[r];
const bg = r % 2 === 0 ? 'rgba(255,255,255,0.015)' : 'transparent';
tableHtml += `<tr style="background:${bg};border-bottom:1px solid #222;">`;
for (let i = 0; i < tableHeaders.length; i++) {
const align = tableAlignments[i] || 'left';
tableHtml += `<td style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(row[i] || '')}</td>`;
}
tableHtml += `</tr>`;
}
tableHtml += "</tbody></table>";
htmlLines.push(tableHtml);
inTable = false;
tableHeaders = []; tableAlignments = []; tableRows = [];
}
}
for (let line of rawLines) {
const trimmed = line.trim();
if (trimmed.startsWith("```")) {
closeUl(); closeOl(); closeTable();
if (inCodeBlock) {
const rCode = codeContent.join("\n");
const langBadge = codeLang ? `<div style="position:absolute;top:6px;left:10px;color:#85c5ec;font-size:10px;font-family:sans-serif;font-weight:bold;text-transform:uppercase;opacity:0.8;user-select:none;">${codeLang}</div>` : '';
const btnStyle = "position:absolute;top:6px;right:6px;background:#333;color:#aaa;border:1px solid #555;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">Copy</button><pre style="background:#1e1e1e;padding:10px;padding-top:28px;border-radius:4px;border:1px solid #333;font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
codeContent = []; codeLang = ""; inCodeBlock = false;
} else {
inCodeBlock = true; codeLang = trimmed.slice(3).trim();
}
continue;
}
if (inCodeBlock) { codeContent.push(line); continue; }
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
closeUl(); closeOl(); closeTable();
htmlLines.push(`<hr style="border:0;border-top:1px solid #333;margin:16px 0;">`);
continue;
}
if (trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
closeUl(); closeOl(); closeTable();
htmlLines.push(`<div style="text-align:center;margin:12px 0;font-family:'Times New Roman',Times,serif;font-size:16px;color:#e5c07b;background:rgba(255,255,255,0.01);padding:10px;border-radius:4px;border:1px dashed #3e4451;font-style:italic;">${trimmed.slice(2, -2).trim()}</div>`);
continue;
}
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
closeUl(); closeOl();
const isSeparator = /^\|[\s\-\:\s\|]+\|$/.test(trimmed);
const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
if (isSeparator) {
tableAlignments = cells.map(c => (c.startsWith(":") && c.endsWith(":")) ? "center" : c.endsWith(":") ? "right" : "left");
inTable = true;
} else {
if (!inTable) { tableHeaders = cells; inTable = true; } else { tableRows.push(cells); }
}
continue;
} else { closeTable(); }
if (trimmed.startsWith(">")) {
closeUl(); closeOl();
const match = trimmed.match(/^([>\s]+)(.*)/);
if (match) {
const depth = (match[1].match(/>/g) || []).length;
let quote = parseInlineMarkdown(match[2].trim());
for (let d = 0; d < depth; d++) {
quote = `<blockquote style="border-left:4px solid #4CAF50;margin:6px 0;padding:6px 12px;color:#aaa;font-style:italic;background-color:rgba(255,255,255,0.015);">${quote}</blockquote>`;
}
htmlLines.push(quote);
}
continue;
}
if (line.startsWith("### ")) {
closeUl(); closeOl();
htmlLines.push(`<h3 style="margin:12px 0 6px 0;color:#ffffff;font-weight:600;font-size:14px;">${parseInlineMarkdown(line.slice(4))}</h3>`);
continue;
} else if (line.startsWith("## ")) {
closeUl(); closeOl();
htmlLines.push(`<h2 style="margin:16px 0 8px 0;color:#ffffff;font-weight:600;font-size:16px;border-bottom:1px solid #333;padding-bottom:4px;">${parseInlineMarkdown(line.slice(3))}</h2>`);
continue;
} else if (line.startsWith("# ")) {
closeUl(); closeOl();
htmlLines.push(`<h1 style="margin:20px 0 10px 0;color:#ffffff;font-weight:700;font-size:18px;border-bottom:2px solid #444;padding-bottom:6px;">${parseInlineMarkdown(line.slice(2))}</h1>`);
continue;
}
const ulMatch = line.match(/^(\s*)([-*])\s(.*)/);
if (ulMatch) {
closeUl();
if (!inUl) { htmlLines.push(`<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">`); inUl = true; }
const indent = ulMatch[1].length;
const content = ulMatch[3].trim();
const paddingLeft = indent > 0 ? `${indent * 8}px` : "0px";
if (content.startsWith("[ ]")) {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
} else if (content.toLowerCase().startsWith("[x]")) {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" checked disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
} else {
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(content)}</li>`);
}
continue;
}
const olMatch = line.match(/^(\s*)(\d+)\.\s(.*)/);
if (olMatch) {
closeUl();
if (!inOl) { htmlLines.push(`<ol style="margin:8px 0;padding-left:24px;list-style-type:decimal;">`); inOl = true; }
const indent = olMatch[1].length;
htmlLines.push(`<li style="margin-left:${indent > 0 ? `${indent * 8}px` : "0px"};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(olMatch[3].trim())}</li>`);
continue;
}
if (trimmed === "") {
closeUl(); closeOl();
htmlLines.push("<br>");
} else {
closeUl(); closeOl();
htmlLines.push(`<p style="margin:6px 0;color:#ddd;line-height:1.6;">${parseInlineMarkdown(line)}</p>`);
}
}
closeUl(); closeOl(); closeTable();
return htmlLines.join("\n");
}
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}) {
let rawText = typeof textData === "object" ? (textData.text || "") : String(textData);
let pid = typeof textData === "object" ? textData.pid : "";
let isMarkdownMode = true;
const overlay = new SidebarOverlay({ onDestroy });
const readerBox = document.createElement("div");
Object.assign(readerBox.style, {
background: "rgba(18, 18, 24, 0.95)", backdropFilter: "blur(12px)",
border: "1px solid 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.8)", display: "flex",
flexDirection: "column", gap: "14px", zIndex: "20"
});
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: "#e2e8f0", wordBreak: "break-word",
userSelect: "text", "-webkit-user-select": "text", background: "rgba(0,0,0,0.35)",
padding: "16px 20px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.06)",
scrollbarWidth: "thin", scrollbarColor: "#475569 rgba(0,0,0,0.2)"
});
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()
};
}
+703 -95
View File
File diff suppressed because it is too large Load Diff
+164 -27
View File
@@ -1,19 +1,53 @@
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().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) {
if (!url) return "";
try {
const u = new URL(url, window.location.origin);
if (u.searchParams && u.searchParams.get("filename")) {
return u.searchParams.get("filename");
}
const pathname = u.pathname || "";
return pathname.split("/").pop() || "";
} catch (e) {
return String(url).split("?")[0].split("/").pop() || "";
}
}
export function parseWorkflow(workflow) {
if (!workflow) return null;
if (typeof workflow === "object") return workflow;
@@ -32,37 +66,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;
}
@@ -76,14 +208,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);
@@ -99,7 +231,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 });
}
@@ -108,9 +245,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 || [];
}
+528
View File
@@ -0,0 +1,528 @@
import { is3DFormat } from "./utils.js";
import { SidebarOverlay } from "./overlay.js";
let cachedThreeLibs = null;
async function getThreeLibs() {
if (cachedThreeLibs) return cachedThreeLibs;
if (window.THREE && window.THREE.GLTFLoader && window.THREE.OrbitControls) {
cachedThreeLibs = window.THREE;
return cachedThreeLibs;
}
try {
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
import("https://esm.sh/three@0.170.0"),
import("https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js"),
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/GLTFLoader.js"),
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/OBJLoader.js"),
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/STLLoader.js"),
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/PLYLoader.js")
]);
cachedThreeLibs = {
...three,
OrbitControls: controls.OrbitControls,
GLTFLoader: gltf.GLTFLoader,
OBJLoader: obj.OBJLoader,
STLLoader: stl.STLLoader,
PLYLoader: ply.PLYLoader
};
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 = baseSrc.split("/").pop().split("?")[0] || "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}...`;
if (format === "OBJ") {
const { OBJExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/OBJExporter.js");
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 { GLTFExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/GLTFExporter.js");
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 { STLExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/STLExporter.js");
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 = srcUrl.split("/").pop().split("?")[0] || "3D Model";
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.textContent = "Failed to load 3D engine.";
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 ext = (srcUrl.split("?")[0].split(".").pop() || "glb").toLowerCase();
let loadedObj = null;
if (ext === "obj" && THREE.OBJLoader) {
const loader = new THREE.OBJLoader();
loadedObj = await loader.loadAsync(srcUrl);
} else if (ext === "stl" && THREE.STLLoader) {
const loader = new THREE.STLLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4, metalness: 0.2 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (ext === "ply" && THREE.PLYLoader) {
const loader = new THREE.PLYLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (THREE.GLTFLoader) {
const loader = new THREE.GLTFLoader();
const gltfData = await loader.loadAsync(srcUrl);
loadedObj = gltfData.scene || gltfData.scenes?.[0];
}
if (loadedObj) {
currentModel = loadedObj;
currentModel.traverse((child) => {
if (child.isMesh && child.material) child._originalMaterial = child.material;
});
applyMaterialMode();
applyUpDirection();
scene.add(currentModel);
fitCameraToObject(camera, currentModel, controls, THREE);
gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
}
} catch (err) {
console.error("Comfy Sidebar: Failed to parse 3D model", err);
loadingSpinner.innerHTML = `<span style="color:#f87171;">Failed to load 3D format</span>`;
return;
}
loadingSpinner.style.display = "none";
};
function fitCameraToObject(cam, obj, ctrl, THREE) {
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
if (cam.isPerspectiveCamera) {
const fov = cam.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
cam.position.set(center.x, center.y + size.y * 0.2, center.z + cameraZ);
} else if (cam.isOrthographicCamera) {
cam.position.set(center.x, center.y + size.y * 0.2, center.z + maxDim * 2);
}
cam.lookAt(center);
ctrl.target.copy(center);
ctrl.update();
}
initSceneAndLoad(baseSrc);
return {
is3D: true,
loadTarget(targetSrc) {
if (!is3DFormat(targetSrc)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
initSceneAndLoad(targetSrc);
},
destroy: () => overlay.destroy()
};
}
+1 -1
View File
@@ -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.0"
version = "1.4.1"
license = { file = "LICENSE" }
dependencies = []