Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93413a729 | ||
|
|
782d30b490 | ||
|
|
0a2db6b85f | ||
|
|
5a4c9f99a5 | ||
|
|
141a0872d5 | ||
|
|
108ac61bec | ||
|
|
b31cfb5f8a | ||
|
|
fe154b7787 | ||
|
|
da5756d61c | ||
|
|
370f4fc58c | ||
|
|
73a9092001 | ||
|
|
9797d1ac7a | ||
|
|
d0424d0d05 | ||
|
|
c72407690c | ||
|
|
31c994a1b6 | ||
|
|
9219f2d042 | ||
|
|
b3e6b1bd62 | ||
|
|
2dc92d3e6f | ||
|
|
e83c999da7 | ||
|
|
f61d6c8902 | ||
|
|
689e020230 | ||
|
|
ad4e138eac | ||
|
|
8487150e6f | ||
|
|
0e120f1af1 | ||
|
|
b6bdaefacc | ||
|
|
5adf5ea513 | ||
|
|
f6e76b1789 | ||
|
|
0e648a9fa9 | ||
|
|
684726789f |
@@ -0,0 +1,42 @@
|
||||
name: Publish to Comfy registry & GitHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Gives GitHub permission to create tags & releases
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
# 1. Read version string from pyproject.toml
|
||||
- name: Extract Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(grep -m 1 'version' pyproject.toml | cut -d '"' -f 2)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
# 2. Automatically create Git Tag & GitHub Release
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ env.VERSION }}
|
||||
name: v${{ env.VERSION }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,20 +1,24 @@
|
||||
# ComfyUI-ComfySidebar
|
||||
|
||||
A lightweight, high-performance ComfyUI extension that replaces the cluttered stock Job History/Queue interface with a compact drag&drop queue panel, inspired by the pre-vue ComfyUI frontend design.
|
||||
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
|
||||
|
||||
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* **Advanced Drag & Drop Support**: Designed specifically for precise mouse pointer controls. Easily drag previous generations back to the empty canvas to restore workflows, or drop images directly into `LoadImage` and input nodes to hot-swap them.
|
||||
* **High-Visibility Preview Cards**: Keep your past runs instantly recognizable. Supports aspect-ratio locked thumbnails for images and auto-playing muted loops for video formats, as well as clean rendering for multi-line text outputs.
|
||||
* **Quick Save Buttons**: Dedicated interactive shortcuts appear on hover, allowing you to instantly download generation outputs or export exact workflow JSON files with a single click.
|
||||
* **Compact Adaptive Design**: A clean, smart layout that automatically balances single or multi-column grids based on your sidebar width. It maximizes viewable content and respects your active screen area without wasting precious space.
|
||||
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
|
||||
* A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
|
||||
* Toggles to hide buttons you don't need from the side panel.
|
||||
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then `Shift+Click` another to load it alongside the first one for pixel by pixel comparison.
|
||||
* **[NEW]** Prevent selected nodes from showing the output in the queue bar and intermediate results. `Ctrl+Q` to toggle.
|
||||
* Browse generation results in a compact grid.
|
||||
* Drag results back onto the canvas to restore workflows.
|
||||
* Drag images directly into `LoadImage` and compatible input nodes.
|
||||
* Preview images, videos, and text outputs.
|
||||
* Compare two results side by side with `Shift+Click`.
|
||||
* Download generated files or workflow JSON directly from result cards.
|
||||
* Show queue progress and the node currently being executed.
|
||||
* Hide sidebar tabs and UI elements you don't use.
|
||||
* Optionally replace the stock Job History sidebar.
|
||||
* Optional unified top bar for workflow tabs and controls.
|
||||
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
|
||||
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
|
||||
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
@@ -33,10 +37,32 @@ Restart your ComfyUI server and refresh the browser tab.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
See additional tweaks in ComfyUI settings menu > Comfy Sidebar. From there, you can adjust the layout or fully replace the stock V2 panel by enabling options like **"Comfy Layout"** and **"Replace the stock Job History sidebar with Comfy Queue"**.
|
||||
Open **Settings → Comfy Sidebar** to configure:
|
||||
|
||||
* Queue grid layout
|
||||
* Result aspect ratio
|
||||
* Queue display mode
|
||||
* Working node display
|
||||
* Automatic cleanup of cancelled/failed jobs
|
||||
* Sidebar tabs to hide
|
||||
* Stock Job History replacement
|
||||
* Graph button visibility
|
||||
* Unified top bar layout
|
||||
|
||||
## ⌨️ Shortcuts
|
||||
|
||||
* **`Q`**: Instantly toggles the sidebar panel open or closed. Safely ignores inputs if you are currently typing inside a text field, input area, or combo box.
|
||||
* **`Ctrl+Q on a selected node`**: Sets the node to be ignored in the sidebar panel results.
|
||||
* **`Click`** on a card to enter preview. **`Shift+Click`** on another card in preview to enter compare mode.
|
||||
| Shortcut | Action |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `Q` | Toggle ComfySidebar |
|
||||
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
| `Ctrl+Click` | Open file location/alternative action |
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
ComfySidebar integrates with the ComfyUI frontend and therefore depends on parts of its UI structure. Frontend changes in ComfyUI may require corresponding updates to this extension.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
+169
-11
@@ -1,20 +1,178 @@
|
||||
# Standard ComfyUI extension loader
|
||||
from server import PromptServer
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from aiohttp import web
|
||||
import uuid
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
# Tells ComfyUI to serve and load the contents of the js/ folder
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
# Create a unique session ID for this backend boot to track server restarts
|
||||
server_session_id = str(uuid.uuid4())
|
||||
|
||||
# Register a custom route to check for backend restarts
|
||||
@PromptServer.instance.routes.get("/classic-sidebar/session")
|
||||
async def get_session_id(request):
|
||||
return web.json_response({"session_id": server_session_id})
|
||||
def is_path_safe(base_dir, target_path):
|
||||
"""Prevents Path Traversal attacks."""
|
||||
real_base = os.path.realpath(base_dir)
|
||||
real_target = os.path.realpath(target_path)
|
||||
return real_target == real_base or real_target.startswith(real_base + os.sep)
|
||||
|
||||
|
||||
def get_all_candidate_roots(subfolder="", folder_type="output"):
|
||||
"""Gathers all potential ComfyUI base directories (output, model folders, extra paths)."""
|
||||
roots = []
|
||||
try:
|
||||
roots.append(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Include specific model categories (loras, checkpoints, etc.)
|
||||
categories = ["loras", "checkpoints", "vae", "unet", "clip", "embeddings", "upscale_models"]
|
||||
if subfolder and subfolder in folder_paths.folder_names_and_paths:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(subfolder))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cat in categories:
|
||||
try:
|
||||
roots.extend(folder_paths.get_folder_paths(cat))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
roots.append(folder_paths.get_input_directory())
|
||||
roots.append(folder_paths.get_temp_directory())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplicate while preserving existence
|
||||
unique = []
|
||||
for r in roots:
|
||||
if r and r not in unique and os.path.exists(r):
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_existing_path(filename="", subfolder="", folder_type="output"):
|
||||
"""Finds the actual location of a file or folder across all candidate root directories."""
|
||||
candidates = get_all_candidate_roots(subfolder, folder_type)
|
||||
|
||||
if filename:
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
p1 = os.path.join(root, subfolder, filename)
|
||||
if os.path.isfile(p1) and is_path_safe(root, p1):
|
||||
return p1, root
|
||||
p2 = os.path.join(root, filename)
|
||||
if os.path.isfile(p2) and is_path_safe(root, p2):
|
||||
return p2, root
|
||||
|
||||
for root in candidates:
|
||||
if subfolder:
|
||||
d1 = os.path.join(root, subfolder)
|
||||
if os.path.isdir(d1) and is_path_safe(root, d1):
|
||||
return d1, root
|
||||
if os.path.isdir(root):
|
||||
return root, root
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def open_file_in_os(target_path):
|
||||
"""Fast, native cross-platform explorer launch."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
norm_path = os.path.normpath(target_path)
|
||||
if not os.path.exists(norm_path):
|
||||
raise FileNotFoundError(f"Path not found: {norm_path}")
|
||||
if os.path.isfile(norm_path):
|
||||
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)
|
||||
+531
-148
@@ -1,12 +1,227 @@
|
||||
import { State } from "./state.js";
|
||||
import { isVideoFormat } from "./utils.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 videoSyncActive = false;
|
||||
let syncAnimationFrameId = null;
|
||||
|
||||
// Track the global keydown reference across instances defensively
|
||||
let globalKeydownHandler = null;
|
||||
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);
|
||||
@@ -19,19 +234,28 @@ 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;
|
||||
};
|
||||
|
||||
const setupVideoPlayback = (vid, container) => {
|
||||
if (!vid) return;
|
||||
if (!vid) return () => {};
|
||||
|
||||
const onMetaLoaded = () => {
|
||||
vid.play().catch(()=>{});
|
||||
};
|
||||
let videoSyncActive = true;
|
||||
let syncAnimationFrameId = null;
|
||||
|
||||
const onMetaLoaded = () => { vid.play().catch(()=>{}); };
|
||||
vid.addEventListener("loadedmetadata", onMetaLoaded);
|
||||
if (vid.readyState >= 1) onMetaLoaded();
|
||||
|
||||
@@ -66,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");
|
||||
@@ -76,7 +298,7 @@ const setupVideoPlayback = (vid, container) => {
|
||||
|
||||
const scrub = (e) => {
|
||||
const rect = scrubberContainer.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientX = getClientX(e);
|
||||
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
|
||||
const duration = vid.duration || 0;
|
||||
if (duration > 0) {
|
||||
@@ -99,10 +321,8 @@ const setupVideoPlayback = (vid, container) => {
|
||||
controlBar.append(playBtn, scrubberContainer, timeLabel);
|
||||
container.appendChild(controlBar);
|
||||
|
||||
videoSyncActive = true;
|
||||
const syncLoop = () => {
|
||||
if (!videoSyncActive) return;
|
||||
|
||||
const cur = vid.currentTime || 0;
|
||||
const dur = vid.duration || 0;
|
||||
if (dur > 0) {
|
||||
@@ -114,13 +334,14 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||
}
|
||||
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
syncAnimationFrameId = requestAnimationFrame(syncLoop);
|
||||
};
|
||||
requestAnimationFrame(syncLoop);
|
||||
|
||||
return () => {
|
||||
videoSyncActive = false;
|
||||
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
|
||||
window.removeEventListener("mousemove", handleWindowMove);
|
||||
window.removeEventListener("touchmove", handleWindowMove);
|
||||
window.removeEventListener("mouseup", handleWindowUp);
|
||||
@@ -130,85 +351,56 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
// DEFENSIVE CHECK: Force-remove any globally orphaned keydown handlers before binding a new one
|
||||
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);
|
||||
|
||||
// Muted bottom tooltip helper
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", zIndex: "30",
|
||||
color: "#555", fontSize: "10px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#777;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
hintPrompt.style.display = "none";
|
||||
}
|
||||
container.appendChild(hintPrompt);
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
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, {
|
||||
@@ -223,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}%`;
|
||||
@@ -242,117 +429,313 @@ function createComparisonViewer(baseSrc) {
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupMedia = (el) => {
|
||||
if (el && el.tagName === "VIDEO") {
|
||||
el.pause();
|
||||
el.src = "";
|
||||
el.load();
|
||||
}
|
||||
};
|
||||
|
||||
const syncImageScales = () => {
|
||||
if (!mediaA) return;
|
||||
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
|
||||
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
|
||||
|
||||
if (!mediaB) {
|
||||
slider.style.display = "none";
|
||||
if (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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
if (e.button && e.button !== 0) return;
|
||||
dragStartX = getClientX(e);
|
||||
dragStartY = getClientY(e);
|
||||
dragMoved = false;
|
||||
isDraggingSlider = true;
|
||||
};
|
||||
|
||||
const doDrag = (e) => {
|
||||
if (!isDragging) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
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);
|
||||
};
|
||||
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 destroy = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
videoSyncActive = false;
|
||||
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
|
||||
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
|
||||
});
|
||||
|
||||
container.remove();
|
||||
activeComparisonViewer = null;
|
||||
};
|
||||
wrapper.addEventListener("click", () => {
|
||||
if (dragMoved || isBaseVideo) return;
|
||||
toggleZoom();
|
||||
});
|
||||
|
||||
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) {
|
||||
// Shift+Click: Sideload target image B for comparison split
|
||||
if (mediaB) mediaB.remove();
|
||||
|
||||
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 {
|
||||
// Standard Click: Replace the main base reference image entirely
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
mediaA.src = targetSrc;
|
||||
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
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;
|
||||
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
|
||||
|
||||
const item = imgSrcs[0];
|
||||
|
||||
// Handle Text Output
|
||||
if (typeof item === "object" && item.text) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isText) {
|
||||
activeComparisonViewer.loadTarget(item);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = createTextReader(
|
||||
item,
|
||||
(target) => showFullscreenPreview([target]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
|
||||
const src = typeof item === "string" ? item : (item.url || item.filename || "");
|
||||
|
||||
// Delegate 3D formats
|
||||
if (is3DFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.is3D) {
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = create3DViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate Audio formats
|
||||
if (isAudioFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isAudio) {
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = createAudioViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate 2D/Video formats
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
} else {
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(
|
||||
src,
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
}
|
||||
Vendored
+149
-75
@@ -1,111 +1,185 @@
|
||||
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);
|
||||
if (!response.ok) return null;
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], imageObj.filename || "dropped_image.png", { type: blob.type });
|
||||
const file = new File([blob], imageObj.filename || "dropped_file", { type: blob.type });
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("overwrite", "true");
|
||||
formData.append("type", "input");
|
||||
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
|
||||
if (!uploadRes.ok) return null;
|
||||
const uploadData = await uploadRes.json();
|
||||
return uploadData.name;
|
||||
} catch(e) {
|
||||
console.error("Comfy Sidebar: Failed to copy image to input.", e);
|
||||
console.error("Comfy Sidebar: Failed to copy file to input.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorkflowDrop(e) {
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
if (!jsonStr) return false;
|
||||
try {
|
||||
const workflow = JSON.parse(jsonStr);
|
||||
if (workflow && workflow.nodes) {
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(workflow);
|
||||
} else if (app.handleFile) {
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleMediaDrop(e) {
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname !== "/view") return false;
|
||||
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
if (!filename) return false;
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return false;
|
||||
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else if (canvas.canvas) {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || w.name === "audio" || w.name === "audio_file" ||
|
||||
w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" ||
|
||||
w.type === "image" || w.type === "customtext"
|
||||
);
|
||||
|
||||
const isLoadNode = targetNode && (
|
||||
widget ||
|
||||
targetNode.type?.includes("LoadImage") ||
|
||||
targetNode.type?.includes("LoadVideo") ||
|
||||
targetNode.type?.includes("VHS_LoadVideo") ||
|
||||
targetNode.type?.includes("Load3D") ||
|
||||
targetNode.type?.includes("Load 3D") ||
|
||||
targetNode.type?.includes("LoadAudio") ||
|
||||
targetNode.type?.includes("Load Audio") ||
|
||||
targetNode.type?.includes("VHS_LoadAudio") ||
|
||||
targetNode.type?.includes("Preview3D")
|
||||
);
|
||||
|
||||
if (isLoadNode) {
|
||||
const targetWidget = widget || targetNode.widgets?.[0];
|
||||
if (targetWidget) {
|
||||
const newFilename = await promoteOrUploadToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
|
||||
if (!targetWidget.options.values.includes(newFilename)) {
|
||||
targetWidget.options.values.push(newFilename);
|
||||
}
|
||||
}
|
||||
targetWidget.value = newFilename;
|
||||
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const dropHandlers = [
|
||||
handleWorkflowDrop,
|
||||
handleMediaDrop
|
||||
];
|
||||
|
||||
export function setupDragAndDrop() {
|
||||
document.addEventListener("dragover", (e) => {
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("drop", async (e) => {
|
||||
// Cancel loading entirely if dropping inside the sidebar area
|
||||
const onDrop = async (e) => {
|
||||
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
|
||||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
|
||||
if (isSidebarDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return;
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
|
||||
// 1. Check for standard workflow JSON data drop
|
||||
const jsonStr = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain");
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const workflow = JSON.parse(jsonStr);
|
||||
if (workflow && workflow.nodes) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(workflow);
|
||||
} else if (app.handleFile) {
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
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 native image URL drop
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname === "/view") {
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
// 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 {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
|
||||
// Dropped on a node with image input: Copy/Upload image to inputs
|
||||
const widget = targetNode.widgets.find(w => w.name === "image");
|
||||
if (widget) {
|
||||
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
widget.value = newFilename;
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas: Load workflow natively
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename || "workflow.png", { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
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);
|
||||
};
|
||||
}
|
||||
+327
-184
@@ -1,21 +1,28 @@
|
||||
// layout.js - Fully Fixed & Renamed to 'Hide Junk'
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
|
||||
.actionbar-container,
|
||||
.shadow-interface.rounded-lg.bg-comfy-menu-bg,
|
||||
.shadow-interface.rounded-lg.border-interface-stroke,
|
||||
.shadow-interface.border.rounded-lg:has([class*="actionbar-buttons"]),
|
||||
.shadow-interface.border.rounded-lg:has(.actionbar-buttons),
|
||||
div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[class*="actionbar"]:not(.actionbar),
|
||||
[class*="actionbar-buttons"],
|
||||
.actionbar-buttons {
|
||||
position: fixed !important;
|
||||
top: -5px !important;
|
||||
top: 3px !important;
|
||||
right: 4px !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
z-index: 1010 !important;
|
||||
|
||||
/* Nuke the background rectangle, border, and shadows */
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
@@ -25,14 +32,48 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
|
||||
.comfy-sidebar-extensions-override {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
height: auto !important;
|
||||
position: static !important;
|
||||
order: -10 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
zoom: 0.8 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
img[alt*="User Avatar"],
|
||||
img[alt*="user avatar"],
|
||||
button:has(img[alt*="User Avatar"]),
|
||||
[role="button"]:has(img[alt*="User Avatar"]),
|
||||
button:has(img[alt*="user avatar"]),
|
||||
[role="button"]:has(img[alt*="user avatar"]),
|
||||
.p-avatar,
|
||||
[data-pc-name="avatar"],
|
||||
button:has(.p-avatar),
|
||||
[role="button"]:has(.p-avatar),
|
||||
button:has([data-pc-name="avatar"]),
|
||||
[role="button"]:has([data-pc-name="avatar"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 150px !important;
|
||||
padding-right: 580px !important;
|
||||
}
|
||||
|
||||
/* Medium desktop screens (between 1200px and 1599px width) */
|
||||
@media (max-width: 1599px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -41,7 +82,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -50,7 +90,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -60,59 +99,95 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
}
|
||||
`;
|
||||
|
||||
export function applyClassicLayout(enable) {
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
|
||||
if (app.extensionManager?.setting) {
|
||||
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
|
||||
} else if (app.ui?.settings) {
|
||||
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
|
||||
}
|
||||
|
||||
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
|
||||
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
|
||||
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
|
||||
}
|
||||
|
||||
export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (app.ui && app.ui.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
}
|
||||
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
|
||||
if (app.ui && app.ui.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Properties Panel Toggle Corrections --- */
|
||||
|
||||
let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
|
||||
if (icon) {
|
||||
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
|
||||
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig && orig.parentNode) return orig.parentNode;
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("queue") || title.includes("manager")) {
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
@@ -120,8 +195,24 @@ function findTopbarContainer() {
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
|
||||
return !!(icon && icon.className.includes("lucide--panel-left"));
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
|
||||
}
|
||||
|
||||
const origBtn = findOriginalButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
@@ -131,7 +222,6 @@ function findActiveQueueIndicator() {
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
@@ -140,199 +230,240 @@ function findActiveQueueIndicator() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Direct mapping from tab settings to their unique class-based HTML icon tags
|
||||
const tabIconSelectors = {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"]',
|
||||
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"NodesMap": '[class*="pi-sitemap"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
|
||||
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
|
||||
// Read from renamed "Hide Junk" path
|
||||
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
|
||||
|
||||
const icon = sidebar.querySelector(selector);
|
||||
if (icon) {
|
||||
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
|
||||
|
||||
if (shouldHide) {
|
||||
if (tabBtn.style.display !== "none") {
|
||||
tabBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (tabBtn.style.display === "none") {
|
||||
tabBtn.style.removeProperty("display");
|
||||
}
|
||||
if (tabBtn.style.display === "none") tabBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle "Override Stock Job History Tab" using its renamed setting path
|
||||
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
|
||||
if (historyIcon) {
|
||||
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
|
||||
if (hideStockHistory) {
|
||||
if (historyBtn.style.display !== "none") {
|
||||
historyBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (historyBtn.style.display === "none") {
|
||||
historyBtn.style.removeProperty("display");
|
||||
}
|
||||
if (historyBtn.style.display === "none") historyBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
// 1. Target elements with Tailwind classes, but IGNORE those inside modal settings/sidebars
|
||||
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
|
||||
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
|
||||
for (const el of elements) {
|
||||
// Filter out dialogs, sidebar overlays, settings drawers, and modals
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
|
||||
continue;
|
||||
}
|
||||
return el;
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
return el.closest('.group, .p-1') || el;
|
||||
}
|
||||
|
||||
// 2. Same fallback logic, excluding modals
|
||||
const fallbacks = document.querySelectorAll('button, .bg-secondary-background');
|
||||
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
|
||||
for (const el of fallbacks) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings')) {
|
||||
continue;
|
||||
}
|
||||
const text = (el.textContent || el.getAttribute("title") || "").toLowerCase();
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
|
||||
return el;
|
||||
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
const stockDot = document.querySelector('.comfy-sidebar-hide-original-properties-btn ~ span, .comfy-sidebar-hide-original-properties-btn + span');
|
||||
let customDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
|
||||
if (stockDot) {
|
||||
if (!customDot) {
|
||||
customDot = document.createElement("span");
|
||||
customDot.className = "comfy-sidebar-error-dot inline-flex items-center justify-center rounded-full bg-destructive-background text-white size-2 absolute -top-1 -right-1";
|
||||
customBtn.appendChild(customDot);
|
||||
}
|
||||
} else if (customDot) {
|
||||
customDot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHTML(html) {
|
||||
if (!html) return "";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
const tempDot = tempDiv.querySelector('[class*="destructive"]');
|
||||
if (tempDot) tempDot.remove();
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
export function syncClassicLayout() {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
// 1. Handle "Hide Graph Button" Toggle under the renamed "Hide Junk" path
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") {
|
||||
graphBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") {
|
||||
graphBtn.style.removeProperty("display");
|
||||
try {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Exit early and clean up overrides if Classic Topbar layout is off
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const extensionsPanel = findNativeExtensionsPanel();
|
||||
if (extensionsPanel) {
|
||||
if (isClassicLayoutEnabled) {
|
||||
const container = findTopbarContainer();
|
||||
if (container && extensionsPanel.parentNode !== container) {
|
||||
if (!extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent = extensionsPanel.parentNode;
|
||||
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
|
||||
}
|
||||
container.appendChild(extensionsPanel);
|
||||
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
|
||||
}
|
||||
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
|
||||
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) customBtn.remove();
|
||||
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator && indicator.style.display === "none") indicator.style.removeProperty("display");
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalButton();
|
||||
if (originalBtn && originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) {
|
||||
customBtn.remove();
|
||||
if (originalBtn) {
|
||||
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
|
||||
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
|
||||
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
customBtn.innerHTML = cleanedHTML;
|
||||
if (existingDot) customBtn.appendChild(existingDot);
|
||||
}
|
||||
|
||||
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
|
||||
customBtn.setAttribute("title", titleVal);
|
||||
customBtn.setAttribute("aria-label", titleVal);
|
||||
|
||||
for (const cls of originalBtn.classList) {
|
||||
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
|
||||
customBtn.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
syncErrorBadge(originalBtn, customBtn);
|
||||
} else if (!customBtn.getAttribute("title")) {
|
||||
customBtn.setAttribute("title", "Toggle properties panel");
|
||||
customBtn.setAttribute("aria-label", "Toggle properties panel");
|
||||
}
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator && indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Handle Active Layout elements synchronization
|
||||
const originalBtn = findOriginalButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = savedButtonData.innerHTML;
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(customBtn.style, {
|
||||
position: "relative",
|
||||
zIndex: "1000",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
opacity: "1",
|
||||
pointerEvents: "auto",
|
||||
display: "inline-flex"
|
||||
});
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
if (nativeBtn) nativeBtn.click();
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
if (customBtn) {
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
// 4. Handle "0 active" indicator visibility based on history tab settings
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator) {
|
||||
if (hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") {
|
||||
indicator.style.setProperty("display", "none", "important");
|
||||
}
|
||||
} else {
|
||||
if (indicator.style.display === "none") {
|
||||
if (indicator) {
|
||||
if (hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
|
||||
} else if (indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
updateSidebarTabsVisibility();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
|
||||
} finally {
|
||||
if (domObserver) {
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPropertiesPanelToggleFix() {
|
||||
@@ -343,8 +474,14 @@ export function setupPropertiesPanelToggleFix() {
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-hide-original-properties-btn ~ span,
|
||||
.comfy-sidebar-hide-original-properties-btn + span {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle {
|
||||
order: 99999 !important;
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
margin-left: -8px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
@@ -353,43 +490,36 @@ export function setupPropertiesPanelToggleFix() {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
|
||||
order: 99999 !important;
|
||||
margin-left: 0px !important;
|
||||
margin-right: 0px !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
}
|
||||
|
||||
syncClassicLayout();
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldSync = false;
|
||||
|
||||
const isInternalSidebarMutation = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
return node.closest?.('.comfyui-sidebar, .comfy-sidebar, .comfy-sidebar-comparison-overlay') ||
|
||||
node.classList?.contains('comfy-sidebar-card');
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
|
||||
// Ensure mutation is on an actual Element node (nodeType 1) and not just text node updates
|
||||
const hasElementMutation = Array.from(mutation.addedNodes).some(node => node.nodeType === 1) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1);
|
||||
const hasExternalElementMutation =
|
||||
Array.from(mutation.addedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node)) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node));
|
||||
|
||||
if (!hasElementMutation) continue;
|
||||
|
||||
// Ignore mutations on our own components to avoid feedback loops
|
||||
const isOurNode = Array.from(mutation.addedNodes).some(node =>
|
||||
node.classList?.contains?.("comfy-sidebar-custom-properties-toggle") ||
|
||||
node.id === "comfy-sidebar-layout-fix-styles"
|
||||
);
|
||||
if (isOurNode) continue;
|
||||
|
||||
shouldSync = true;
|
||||
break;
|
||||
if (hasExternalElementMutation) {
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldSync && !syncScheduled) {
|
||||
@@ -405,4 +535,17 @@ export function setupPropertiesPanelToggleFix() {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
syncClassicLayout();
|
||||
}
|
||||
|
||||
export function destroyLayoutFix() {
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = null;
|
||||
}
|
||||
const styleEl = document.getElementById(STYLE_ID);
|
||||
if (styleEl) styleEl.remove();
|
||||
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
|
||||
if (fixStyles) fixStyles.remove();
|
||||
}
|
||||
+101
@@ -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();
|
||||
}
|
||||
}
|
||||
+268
-95
@@ -1,7 +1,7 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage, cardElements } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
|
||||
import { State, promptStates, pruneHistory, cardElements, scheduleStateSave } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js";
|
||||
|
||||
export let renderDOMFn = () => {};
|
||||
export let updateSidebarBadgeFn = () => {};
|
||||
@@ -13,10 +13,68 @@ export function setUIDependencies(renderFn, badgeFn) {
|
||||
export async function syncQueue() {
|
||||
try {
|
||||
const q = await api.getQueue();
|
||||
|
||||
const runningList = q.Running || q.queue_running || [];
|
||||
const pendingList = q.Pending || q.queue_pending || [];
|
||||
const pendingIds = new Set();
|
||||
const 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)) {
|
||||
@@ -26,7 +84,7 @@ export async function syncQueue() {
|
||||
pid = p.prompt_id || p.id || p.uuid;
|
||||
seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx);
|
||||
}
|
||||
return { pid, seq, original: p };
|
||||
return { pid: pid ? String(pid) : null, seq, original: p };
|
||||
});
|
||||
|
||||
normalizedPending.sort((a, b) => a.seq - b.seq);
|
||||
@@ -37,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 {
|
||||
@@ -54,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);
|
||||
@@ -65,35 +127,74 @@ export async function syncQueue() {
|
||||
}
|
||||
|
||||
const concludeRun = async (pid, statusStr) => {
|
||||
if (!pid || !promptStates.has(pid)) return;
|
||||
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
|
||||
const key = String(pid);
|
||||
if (!key || !promptStates.has(key)) return;
|
||||
if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null;
|
||||
|
||||
const st = promptStates.get(pid);
|
||||
const st = promptStates.get(key);
|
||||
|
||||
if (st._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
delete st._previewBlobUrl;
|
||||
}
|
||||
if (st._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._oldPreviewBlobUrl); } catch(e){}
|
||||
delete st._oldPreviewBlobUrl;
|
||||
}
|
||||
|
||||
st.status = statusStr;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
st.endTime = Date.now();
|
||||
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/history/${pid}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[pid]) {
|
||||
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
|
||||
st.nodeOutputs = hItem[pid].outputs; // Store full outputs dictionary!
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow);
|
||||
st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow);
|
||||
}
|
||||
} catch (err) {}
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[key]) {
|
||||
const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null;
|
||||
st.workflow = parseWorkflow(rawWf) || st.workflow;
|
||||
st.nodeOutputs = hItem[key].outputs;
|
||||
|
||||
const 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;
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow);
|
||||
if (fetchedTexts.length > 0) st.texts = fetchedTexts;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
pruneHistory(app);
|
||||
scheduleStateSave();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
// Auto-sweep cancelled and errored jobs if setting is enabled
|
||||
const onStatus = () => syncQueue();
|
||||
const onReconnected = async () => {
|
||||
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
|
||||
await initSessionAndHistory();
|
||||
await syncQueue();
|
||||
};
|
||||
|
||||
const onExecutionStart = (e) => {
|
||||
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
|
||||
const toDelete = [];
|
||||
for (const [p, s] of promptStates.entries()) {
|
||||
@@ -104,132 +205,204 @@ export function setupApiListeners() {
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
|
||||
scheduleStateSave();
|
||||
}
|
||||
}
|
||||
|
||||
const pid = e.detail.prompt_id;
|
||||
const pid = String(e.detail.prompt_id);
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
|
||||
st.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(); // Render immediately to prevent pop-in delay!
|
||||
renderDOMFn();
|
||||
syncQueue();
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("progress", (e) => {
|
||||
const pid = e.detail.prompt_id;
|
||||
const onProgress = (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
promptStates.get(pid).progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
renderDOMFn();
|
||||
const st = promptStates.get(pid);
|
||||
st.progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
|
||||
const cardObj = cardElements.get(pid);
|
||||
if (cardObj && cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${st.progress}%`;
|
||||
if (cardObj.statusText && cardObj.statusText.style.display !== "none") {
|
||||
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "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];
|
||||
if (st._previewBlobUrl) try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
st._previewBlobUrl = URL.createObjectURL(e.detail);
|
||||
st.images = [{ url: st._previewBlobUrl }];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
st._oldPreviewBlobUrl = st._previewBlobUrl;
|
||||
st._previewBlobUrl = newBlobUrl;
|
||||
st.images = [{ url: newBlobUrl }];
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("executed", (e) => {
|
||||
if (promptStates.has(e.detail.prompt_id)) {
|
||||
const st = promptStates.get(e.detail.prompt_id);
|
||||
const finalImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalImgs.length > 0) {
|
||||
// Overwrite with latest images to avoid accumulation across sequential nodes
|
||||
st.images = finalImgs;
|
||||
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);
|
||||
const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeImgs.length > 0) {
|
||||
st.images = nodeImgs;
|
||||
}
|
||||
const finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalTexts.length > 0) {
|
||||
// Overwrite with latest texts to avoid clearing previously set text outputs
|
||||
st.texts = finalTexts;
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
st.texts = nodeTexts;
|
||||
}
|
||||
|
||||
// Dynamically capture each executed node's individual outputs
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
st.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("execution_success", (e) => concludeRun(e.detail.prompt_id, "completed"));
|
||||
api.addEventListener("execution_error", (e) => concludeRun(e.detail.prompt_id, "error"));
|
||||
api.addEventListener("execution_interrupted", () => {
|
||||
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() {
|
||||
let backendSessionId = null;
|
||||
try {
|
||||
const res = await fetch("/classic-sidebar/session");
|
||||
const data = await res.json();
|
||||
backendSessionId = data.session_id;
|
||||
} catch (err) {
|
||||
console.warn("Comfy Sidebar: Custom session endpoint not found. Queue persistence will fall back to cache.");
|
||||
}
|
||||
const historyData = await api.getHistory();
|
||||
if (!historyData) return;
|
||||
|
||||
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
|
||||
if (backendSessionId && backendSessionId !== storedSessionId) {
|
||||
// Session changed (server restarted)! Clear cached memory.
|
||||
promptStates.clear();
|
||||
cardElements.clear();
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
|
||||
} else if (!storedSessionId && backendSessionId) {
|
||||
localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
|
||||
}
|
||||
const rawItems = [];
|
||||
if (Array.isArray(historyData.History)) {
|
||||
historyData.History.forEach(item => {
|
||||
if (item && item.prompt_id) rawItems.push({ pid: String(item.prompt_id), data: item });
|
||||
});
|
||||
} else if (typeof historyData === 'object') {
|
||||
Object.keys(historyData).forEach(pidKey => {
|
||||
const item = historyData[pidKey];
|
||||
rawItems.push({ pid: String(pidKey), data: Array.isArray(item) ? { prompt: item[0], outputs: item[1], status: item[2] } : item });
|
||||
});
|
||||
}
|
||||
|
||||
const historyData = await api.getHistory();
|
||||
const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b));
|
||||
ids.forEach(id => {
|
||||
if (promptStates.has(id)) return;
|
||||
const workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null;
|
||||
const images = findImagesInOutputs(historyData[id].outputs, workflow);
|
||||
const texts = findTextsInOutputs(historyData[id].outputs, workflow);
|
||||
if (images.length === 0 && texts.length === 0) return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(id, {
|
||||
pid: id, status: "completed", images, texts,
|
||||
nodeOutputs: historyData[id].outputs, // Store full outputs dictionary!
|
||||
workflow: workflow,
|
||||
progressText: "", timestamp: State.globalOrderCounter, rendered: true
|
||||
rawItems.sort((a, b) => {
|
||||
const numA = a.data.prompt?.[0] ?? 0;
|
||||
const numB = b.data.prompt?.[0] ?? 0;
|
||||
return numA - numB;
|
||||
});
|
||||
});
|
||||
pruneHistory(app);
|
||||
await syncQueue();
|
||||
|
||||
rawItems.forEach(({ pid, data }) => {
|
||||
const extraData = data.extra_data || data.prompt?.[3] || {};
|
||||
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
const outputs = data.outputs || {};
|
||||
|
||||
const images = getPrimaryOutputImages(outputs, workflow);
|
||||
const texts = findTextsInOutputs(outputs, workflow);
|
||||
|
||||
const statusStr = data.status?.status_str || "completed";
|
||||
const isInterrupted = statusStr === "interrupted" ||
|
||||
(data.status?.messages && JSON.stringify(data.status.messages).toLowerCase().includes("interrupted"));
|
||||
|
||||
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
|
||||
(isInterrupted ? "cancelled" : (statusStr === "error" ? "error" : "cancelled"));
|
||||
|
||||
if (images.length === 0 && texts.length === 0 && status === "completed") return;
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
if (st.status === "active" || st.status === "pending") {
|
||||
st.status = status;
|
||||
st.images = images;
|
||||
st.texts = texts;
|
||||
st.nodeOutputs = outputs;
|
||||
st.workflow = workflow || st.workflow;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
State.sequenceNumber++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
status: status,
|
||||
images: images,
|
||||
texts: texts,
|
||||
nodeOutputs: outputs,
|
||||
workflow: workflow,
|
||||
progressText: "",
|
||||
timestamp: State.sequenceNumber,
|
||||
rendered: true
|
||||
});
|
||||
});
|
||||
|
||||
pruneHistory(app);
|
||||
scheduleStateSave();
|
||||
await syncQueue();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
|
||||
}
|
||||
}
|
||||
+241
-144
@@ -1,10 +1,136 @@
|
||||
// sidebar.js - Fully Fixed Real-Time UI Sync (With setTimeout deferred updates)
|
||||
import { app } from "/scripts/app.js";
|
||||
import { injectStyles } from "./styles.js";
|
||||
import { setupDragAndDrop } from "./dragdrop.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM, setupScrollListener } from "./ui.js";
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./layout.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
let cleanupFns = [];
|
||||
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
const nodeEl = document.querySelector(
|
||||
`.comfy-node[data-node-id="${node.id}"], ` +
|
||||
`[data-node-id="${node.id}"], ` +
|
||||
`[data-id="${node.id}"]`
|
||||
);
|
||||
if (!nodeEl) return;
|
||||
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
if (!titleTextNode) {
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) badge.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncAllNodeBadges() {
|
||||
applySidebarOverride();
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
}
|
||||
|
||||
function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
@@ -22,43 +148,37 @@ function toggleIgnoreActiveNode() {
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
});
|
||||
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
// 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.replace(/^(🚫|👁️⃠)\s*/, "").trim();
|
||||
|
||||
// Grab all potential elements that could hold the title text
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
// Ensure we are selecting a small title element, not the entire node wrapper
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
// Standard Comfy Queue Settings
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
|
||||
// Add settings to hide individual sidebar tabs under the "Hide Junk" sub-category
|
||||
// We use setTimeout to let Comfy finish writing the setting value before reading it
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "NodesMap", "Templates"];
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
@@ -66,181 +186,137 @@ app.registerExtension({
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Group the Stock History Tab override into "Hide Junk"
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replaces the stock Job History sidebar with Comfy Queue",
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui && app.ui.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
});
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the toggle setting for Graph Button under "Hide Junk"
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the toggle setting for Classic / Comfy Layout
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
applyClassicLayout(value);
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode()
|
||||
}
|
||||
],
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncNodeVueBadge(node, true);
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => syncAllNodeBadges());
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
// Apply saved layout configuration on startup
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled);
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
// Run properties panel toggle correction
|
||||
setupPropertiesPanelToggleFix();
|
||||
|
||||
// Safely resolve circular dependencies using Dependency Injection Hooks
|
||||
setSyncQueue(syncQueue);
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
// 1. Initialize DOM Independent Services
|
||||
injectStyles();
|
||||
setupDragAndDrop();
|
||||
|
||||
// 2. Initialize UI Hierarchy
|
||||
// Register disposable subsystems
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupScrollListener());
|
||||
cleanupFns.push(setupApiListeners());
|
||||
|
||||
const sidebarContainer = setupSidebarUI();
|
||||
|
||||
// 3. Connect API Hooks
|
||||
setupApiListeners();
|
||||
|
||||
// 4. Connect Storage and History Sync
|
||||
await initSessionAndHistory();
|
||||
|
||||
// 5. Connect UI Keyboard & Override Hacks
|
||||
document.addEventListener("keydown", (e) => {
|
||||
setupVueNodeObserver();
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
// Toggle Queue open/close (Q)
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
// Toggle Ignore Node in Queue (Ctrl + Q)
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
}, true);
|
||||
|
||||
setInterval(() => {
|
||||
applySidebarOverride();
|
||||
|
||||
// Sync ignoreInQueue visual states across both canvas and Vue DOM modes dynamically
|
||||
if (app.graph && app.graph._nodes) {
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
// 1. Sweep away legacy title emoji residue to keep graph titles perfectly clean
|
||||
if (node.title && (node.title.startsWith("🚫 ") || node.title.startsWith("👁️⃠ "))) {
|
||||
node.title = node.title.replace(/^(🚫|👁️⃠)\s*/, "");
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Classic LiteGraph Native Glowing Border Sync
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
// 3. Nodes 2.0 DOM Sibling Support (Vue Mode)
|
||||
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
|
||||
if (nodeEl) {
|
||||
// Dynamically locate the node header container by searching for the node title text element
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
|
||||
if (titleTextNode) {
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
// Custom SVG crossed-out eye
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
// Flows natively inside the parent header block with automatic flex alignment
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit", // Inherits color of parent text (automatically morphs on highlight!)
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback cleanup if the title text element is temporarily unmounted
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
applySidebarOverride();
|
||||
|
||||
// 6. Final Registration
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
@@ -249,5 +325,26 @@ app.registerExtension({
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
for (const fn of cleanupFns.splice(0)) {
|
||||
try { if (typeof fn === "function") fn(); } catch (e) {}
|
||||
}
|
||||
destroyLayoutFix();
|
||||
|
||||
if (nodeDOMObserver) {
|
||||
nodeDOMObserver.disconnect();
|
||||
nodeDOMObserver = null;
|
||||
}
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (app.extensionManager?.unregisterSidebarTab) {
|
||||
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
|
||||
}
|
||||
}
|
||||
});
|
||||
+74
-25
@@ -1,56 +1,107 @@
|
||||
export const promptStates = new Map();
|
||||
export const cardElements = new Map();
|
||||
|
||||
// Global shared variables
|
||||
export const State = {
|
||||
currentSearchQuery: "",
|
||||
globalOrderCounter: 0,
|
||||
sequenceNumber: 0,
|
||||
sidebarContainer: null,
|
||||
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);
|
||||
if (state) {
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
}
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
}
|
||||
}
|
||||
promptStates.delete(key);
|
||||
cardElements.delete(key);
|
||||
}
|
||||
|
||||
export function pruneHistory(app) {
|
||||
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
const tasks = Array.from(promptStates.entries())
|
||||
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
|
||||
|
||||
tasks.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
tasks.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
|
||||
|
||||
if (tasks.length > maxItems) {
|
||||
const deleteCount = tasks.length - maxItems;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
const [pid] = tasks[i];
|
||||
promptStates.delete(pid);
|
||||
cardElements.delete(pid);
|
||||
deletePromptState(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStatesToLocalStorage() {
|
||||
try {
|
||||
const serializable = [];
|
||||
let serializable = [];
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
const cleanedImages = (state.images || []).map(img => {
|
||||
if (img.url && img.url.startsWith("blob:")) return null; // Skip blob URLs
|
||||
if (img.url && img.url.startsWith("blob:")) return null;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
|
||||
|
||||
serializable.push({
|
||||
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs, // Persist node level outputs
|
||||
workflow: state.workflow, progress: state.progress || 0, queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "", timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "", rendered: state.rendered || false,
|
||||
startTime: state.startTime, endTime: state.endTime, duration: state.duration
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "",
|
||||
rendered: state.rendered || false,
|
||||
startTime: state.startTime,
|
||||
endTime: state.endTime,
|
||||
duration: state.duration
|
||||
});
|
||||
}
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
try {
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function loadStatesFromLocalStorage() {
|
||||
@@ -59,14 +110,12 @@ export function loadStatesFromLocalStorage() {
|
||||
if (data) {
|
||||
const list = JSON.parse(data);
|
||||
list.forEach(state => {
|
||||
promptStates.set(state.pid, state);
|
||||
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
|
||||
const key = String(state.pid);
|
||||
promptStates.set(key, state);
|
||||
if (state.timestamp > State.sequenceNumber) State.sequenceNumber = state.timestamp;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Load instantly and synchronously on script import to prevent race conditions during early renders!
|
||||
loadStatesFromLocalStorage();
|
||||
+464
-7
@@ -1,6 +1,7 @@
|
||||
// styles.js
|
||||
export function injectStyles() {
|
||||
if (document.getElementById("comfy-sidebar-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-card {
|
||||
background: var(--comfy-input-bg, #181818);
|
||||
@@ -28,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;
|
||||
@@ -35,26 +44,474 @@ export function injectStyles() {
|
||||
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
|
||||
}
|
||||
|
||||
/* --- Custom Queue Cancel Button to Mimic ComfyUI Native Destructive Button --- */
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
display: none;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
background-color: #7f1d1d !important; /* Native deep red */
|
||||
color: #e2e8f0 !important; /* Native slate white icon */
|
||||
font-size: 11px !important; /* Muted native cross size */
|
||||
border-radius: 6px !important; /* Native corner rounding */
|
||||
background-color: #7f1d1d !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 11px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn:hover {
|
||||
background-color: #991b1b !important; /* Slightly brighter red on hover */
|
||||
background-color: #991b1b !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-left-hover-panel {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Card Action Buttons */
|
||||
.comfy-sidebar-card-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 13px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
z-index: 20 !important;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.comfy-sidebar-card-action-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.95) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Delete Confirmation Active State */
|
||||
.comfy-sidebar-card-action-btn.confirm-delete,
|
||||
.comfy-sidebar-card-action-btn.confirm-delete:hover {
|
||||
background-color: #dc3545 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Disk Delete Confirmation */
|
||||
.comfy-sidebar-card-action-btn.confirm-delete-disk,
|
||||
.comfy-sidebar-card-action-btn.confirm-delete-disk:hover {
|
||||
background-color: #7f1d1d !important;
|
||||
color: #ffc9c9 !important;
|
||||
box-shadow: 0 0 10px rgba(220, 38, 38, 0.85) !important;
|
||||
border: 1px solid #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Dynamic Ctrl Key Hover State */
|
||||
.comfy-sidebar-del-label {
|
||||
display: none;
|
||||
margin-left: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
font-family: sans-serif;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-ctrl-active .comfy-sidebar-card-action-btn:not(.comfy-sidebar-btn-del) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-ctrl-active .comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del {
|
||||
background-color: #991b1b !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #ef4444 !important;
|
||||
width: auto !important;
|
||||
padding: 0 8px !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6) !important;
|
||||
}
|
||||
|
||||
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del .comfy-sidebar-del-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Translucent Folder Indicator on Ctrl Press */
|
||||
.comfy-sidebar-ctrl-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #38bdf8;
|
||||
z-index: 15;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.comfy-sidebar-ctrl-overlay i {
|
||||
font-size: 26px;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.comfy-sidebar-ctrl-overlay span {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #f1f5f9;
|
||||
font-family: sans-serif;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
body.comfy-sidebar-ctrl-active .comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-ctrl-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comfy-sidebar-header-btn {
|
||||
background: transparent;
|
||||
color: var(--desc-color, #aaa);
|
||||
border: 1px solid var(--border-color, #555);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-header-btn:hover {
|
||||
border-color: var(--fg-color, #eee);
|
||||
color: var(--fg-color, #eee);
|
||||
}
|
||||
|
||||
.comfy-sidebar-icon-btn {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comfy-sidebar-scroll-top-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
color: #eee;
|
||||
border: 1px solid var(--border-color, #555);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
transition: all 0.15s ease-in-out;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.comfy-sidebar-scroll-top-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--p-primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
border-color: var(--p-primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* 3D Preview Cards */
|
||||
.comfy-sidebar-3d-wrapper {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #0f172a 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-wrapper:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-preview-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.45);
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-preview-img {
|
||||
filter: brightness(0.6);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-icon {
|
||||
font-size: 38px;
|
||||
color: #60a5fa;
|
||||
margin-bottom: 6px;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-icon {
|
||||
transform: scale(1.1) rotate(6deg);
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.comfy-sidebar-3d-title {
|
||||
font-size: 10px;
|
||||
color: #f1f5f9;
|
||||
font-family: monospace;
|
||||
max-width: 85%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Audio Preview Cards */
|
||||
.comfy-sidebar-audio-wrapper {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
background: linear-gradient(135deg, #1e1333 0%, #0f172a 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px 12px 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper:hover {
|
||||
border-color: #a855f7;
|
||||
box-shadow: 0 0 12px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-badge {
|
||||
background: rgba(168, 85, 247, 0.9);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-title {
|
||||
font-size: 10px;
|
||||
color: #f1f5f9;
|
||||
font-family: monospace;
|
||||
max-width: 65%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.comfy-sidebar-soundwave-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 36px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.comfy-sidebar-soundwave-bar {
|
||||
width: 3px;
|
||||
background: #a855f7;
|
||||
border-radius: 2px;
|
||||
height: 8px;
|
||||
transition: height 0.1s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper.playing .comfy-sidebar-soundwave-bar {
|
||||
background: #c084fc;
|
||||
animation: comfySidebarWave 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes comfySidebarWave {
|
||||
0% { transform: scaleY(0.4); }
|
||||
100% { transform: scaleY(1.3); }
|
||||
}
|
||||
|
||||
/* Generic / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
|
||||
.comfy-sidebar-file-wrapper {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
background: linear-gradient(135deg, #131d2e 0%, #0b1120 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comfy-sidebar-file-wrapper:hover {
|
||||
border-color: #0ea5e9;
|
||||
box-shadow: 0 0 14px rgba(14, 165, 233, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.comfy-sidebar-file-badge {
|
||||
background: rgba(14, 165, 233, 0.9);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comfy-sidebar-file-title {
|
||||
font-size: 11px;
|
||||
color: #f1f5f9;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comfy-sidebar-file-icon {
|
||||
font-size: 14px;
|
||||
color: #38bdf8;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.comfy-sidebar-file-subtext {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.comfy-sidebar-text-clamp {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #cbd5e1;
|
||||
max-height: 84px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-top: 18px;
|
||||
word-break: break-word;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-text-clamp:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,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, "&").replace(/</g, "<").split("\n");
|
||||
let htmlLines = [];
|
||||
let inCodeBlock = false, codeContent = [], codeLang = "";
|
||||
let inUl = false, inOl = false, inTable = false;
|
||||
let tableHeaders = [], tableAlignments = [], tableRows = [];
|
||||
|
||||
function closeUl() { if (inUl) { htmlLines.push("</ul>"); inUl = false; } }
|
||||
function closeOl() { if (inOl) { htmlLines.push("</ol>"); inOl = false; } }
|
||||
function closeTable() {
|
||||
if (inTable) {
|
||||
let tableHtml = `<table style="border-collapse:collapse;width:100%;margin:12px 0;font-size:12px;border:1px solid #333;color:#ddd;text-align:left;">`;
|
||||
if (tableHeaders.length > 0) {
|
||||
tableHtml += `<thead style="background:#1a1a1a;font-weight:600;color:#fff;border-bottom:2px solid #444;"><tr>`;
|
||||
for (let i = 0; i < tableHeaders.length; i++) {
|
||||
const align = tableAlignments[i] || 'left';
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(tableHeaders[i])}</th>`;
|
||||
}
|
||||
tableHtml += `</tr></thead>`;
|
||||
}
|
||||
tableHtml += "<tbody>";
|
||||
for (let r = 0; r < tableRows.length; r++) {
|
||||
const row = tableRows[r];
|
||||
const bg = r % 2 === 0 ? 'rgba(255,255,255,0.015)' : 'transparent';
|
||||
tableHtml += `<tr style="background:${bg};border-bottom:1px solid #222;">`;
|
||||
for (let i = 0; i < tableHeaders.length; i++) {
|
||||
const align = tableAlignments[i] || 'left';
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(row[i] || '')}</td>`;
|
||||
}
|
||||
tableHtml += `</tr>`;
|
||||
}
|
||||
tableHtml += "</tbody></table>";
|
||||
htmlLines.push(tableHtml);
|
||||
inTable = false;
|
||||
tableHeaders = []; tableAlignments = []; tableRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let line of rawLines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
if (inCodeBlock) {
|
||||
const rCode = codeContent.join("\n");
|
||||
const langBadge = codeLang ? `<div style="position:absolute;top:6px;left:10px;color:#85c5ec;font-size:10px;font-family:sans-serif;font-weight:bold;text-transform:uppercase;opacity:0.8;user-select:none;">${codeLang}</div>` : '';
|
||||
const btnStyle = "position:absolute;top:6px;right:6px;background:#333;color:#aaa;border:1px solid #555;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
|
||||
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">Copy</button><pre style="background:#1e1e1e;padding:10px;padding-top:28px;border-radius:4px;border:1px solid #333;font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
|
||||
codeContent = []; codeLang = ""; inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true; codeLang = trimmed.slice(3).trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) { codeContent.push(line); continue; }
|
||||
|
||||
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
htmlLines.push(`<hr style="border:0;border-top:1px solid #333;margin:16px 0;">`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
htmlLines.push(`<div style="text-align:center;margin:12px 0;font-family:'Times New Roman',Times,serif;font-size:16px;color:#e5c07b;background:rgba(255,255,255,0.01);padding:10px;border-radius:4px;border:1px dashed #3e4451;font-style:italic;">${trimmed.slice(2, -2).trim()}</div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
|
||||
closeUl(); closeOl();
|
||||
const isSeparator = /^\|[\s\-\:\s\|]+\|$/.test(trimmed);
|
||||
const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
|
||||
if (isSeparator) {
|
||||
tableAlignments = cells.map(c => (c.startsWith(":") && c.endsWith(":")) ? "center" : c.endsWith(":") ? "right" : "left");
|
||||
inTable = true;
|
||||
} else {
|
||||
if (!inTable) { tableHeaders = cells; inTable = true; } else { tableRows.push(cells); }
|
||||
}
|
||||
continue;
|
||||
} else { closeTable(); }
|
||||
|
||||
if (trimmed.startsWith(">")) {
|
||||
closeUl(); closeOl();
|
||||
const match = trimmed.match(/^([>\s]+)(.*)/);
|
||||
if (match) {
|
||||
const depth = (match[1].match(/>/g) || []).length;
|
||||
let quote = parseInlineMarkdown(match[2].trim());
|
||||
for (let d = 0; d < depth; d++) {
|
||||
quote = `<blockquote style="border-left:4px solid #4CAF50;margin:6px 0;padding:6px 12px;color:#aaa;font-style:italic;background-color:rgba(255,255,255,0.015);">${quote}</blockquote>`;
|
||||
}
|
||||
htmlLines.push(quote);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("### ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h3 style="margin:12px 0 6px 0;color:#ffffff;font-weight:600;font-size:14px;">${parseInlineMarkdown(line.slice(4))}</h3>`);
|
||||
continue;
|
||||
} else if (line.startsWith("## ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h2 style="margin:16px 0 8px 0;color:#ffffff;font-weight:600;font-size:16px;border-bottom:1px solid #333;padding-bottom:4px;">${parseInlineMarkdown(line.slice(3))}</h2>`);
|
||||
continue;
|
||||
} else if (line.startsWith("# ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h1 style="margin:20px 0 10px 0;color:#ffffff;font-weight:700;font-size:18px;border-bottom:2px solid #444;padding-bottom:6px;">${parseInlineMarkdown(line.slice(2))}</h1>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ulMatch = line.match(/^(\s*)([-*])\s(.*)/);
|
||||
if (ulMatch) {
|
||||
closeUl();
|
||||
if (!inUl) { htmlLines.push(`<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">`); inUl = true; }
|
||||
const indent = ulMatch[1].length;
|
||||
const content = ulMatch[3].trim();
|
||||
const paddingLeft = indent > 0 ? `${indent * 8}px` : "0px";
|
||||
if (content.startsWith("[ ]")) {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
|
||||
} else if (content.toLowerCase().startsWith("[x]")) {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" checked disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
|
||||
} else {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(content)}</li>`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const olMatch = line.match(/^(\s*)(\d+)\.\s(.*)/);
|
||||
if (olMatch) {
|
||||
closeUl();
|
||||
if (!inOl) { htmlLines.push(`<ol style="margin:8px 0;padding-left:24px;list-style-type:decimal;">`); inOl = true; }
|
||||
const indent = olMatch[1].length;
|
||||
htmlLines.push(`<li style="margin-left:${indent > 0 ? `${indent * 8}px` : "0px"};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(olMatch[3].trim())}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push("<br>");
|
||||
} else {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<p style="margin:6px 0;color:#ddd;line-height:1.6;">${parseInlineMarkdown(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeUl(); closeOl(); closeTable();
|
||||
return htmlLines.join("\n");
|
||||
}
|
||||
|
||||
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
let rawText = typeof textData === "object" ? (textData.text || "") : String(textData);
|
||||
let pid = typeof textData === "object" ? textData.pid : "";
|
||||
let isMarkdownMode = true;
|
||||
|
||||
const overlay = new SidebarOverlay({ onDestroy });
|
||||
|
||||
const readerBox = document.createElement("div");
|
||||
Object.assign(readerBox.style, {
|
||||
background: "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()
|
||||
};
|
||||
}
|
||||
+204
-30
@@ -1,44 +1,205 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
const s = url.toLowerCase();
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
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");
|
||||
};
|
||||
|
||||
function isNodeIgnored(nodeId, workflow) {
|
||||
if (!workflow || !workflow.nodes) {
|
||||
// Fallback to active graph if workflow is missing (e.g. during live execution)
|
||||
const liveNode = app.graph?.getNodeById(Number(nodeId));
|
||||
if (liveNode) {
|
||||
return !!(liveNode.properties && liveNode.properties.ignoreInQueue);
|
||||
export const isVideoFormat = (url) => {
|
||||
if (!url) return false;
|
||||
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().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;
|
||||
if (typeof workflow === "string") {
|
||||
try { return JSON.parse(workflow); } catch (e) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (!workflow || !Array.isArray(workflow.nodes)) {
|
||||
return false;
|
||||
}
|
||||
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
|
||||
return !!(node && node.properties && node.properties.ignoreInQueue);
|
||||
}
|
||||
|
||||
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
|
||||
export function findImagesInOutputs(outputs, workflow) {
|
||||
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;
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
for (const key in outputs[nodeId]) {
|
||||
const val = outputs[nodeId][key];
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach(item => {
|
||||
if (item && typeof item === 'object' && item.filename) list.push(item);
|
||||
});
|
||||
} else if (val && typeof val === 'object' && val.filename) {
|
||||
list.push(val);
|
||||
const seen = new Set();
|
||||
|
||||
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, 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], targetSub, targetTyp);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function findTextsInOutputs(outputs, workflow) {
|
||||
export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
for (const nodeId in outputs) {
|
||||
@@ -47,14 +208,14 @@ export function findTextsInOutputs(outputs, workflow) {
|
||||
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);
|
||||
@@ -65,12 +226,17 @@ export function findTextsInOutputs(outputs, workflow) {
|
||||
return list;
|
||||
}
|
||||
|
||||
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
|
||||
export function getRunOutputs(nodeOutputs, workflow) {
|
||||
export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
|
||||
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 });
|
||||
}
|
||||
@@ -78,6 +244,13 @@ export function getRunOutputs(nodeOutputs, workflow) {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
|
||||
return runOutputs[runOutputs.length - 1].images || [];
|
||||
}
|
||||
|
||||
export function matchesFilter(state, query) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -87,8 +260,9 @@ export function matchesFilter(state, query) {
|
||||
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
|
||||
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
|
||||
|
||||
if (state.workflow && Array.isArray(state.workflow.nodes)) {
|
||||
for (const node of state.workflow.nodes) {
|
||||
const workflow = parseWorkflow(state.workflow);
|
||||
if (workflow && Array.isArray(workflow.nodes)) {
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.title && node.title.toLowerCase().includes(q)) return true;
|
||||
if (node.type && node.type.toLowerCase().includes(q)) return true;
|
||||
if (Array.isArray(node.widgets)) {
|
||||
|
||||
+528
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.4.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "soundslikethunder"
|
||||
DisplayName = "Comfy Sidebar"
|
||||
Reference in New Issue
Block a user