Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe154b7787 | ||
|
|
da5756d61c | ||
|
|
370f4fc58c | ||
|
|
73a9092001 | ||
|
|
9797d1ac7a | ||
|
|
d0424d0d05 | ||
|
|
c72407690c | ||
|
|
31c994a1b6 | ||
|
|
9219f2d042 |
@@ -1,20 +1,22 @@
|
||||
# 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`.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
@@ -33,10 +35,31 @@ 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`**: Toggle sidebar queue panel.
|
||||
* **`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` | Toggle sidebar output filtering for selected nodes |
|
||||
| `Click` | Open result preview |
|
||||
| `Shift+Click` | Compare another result |
|
||||
|
||||
## 📝 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).
|
||||
+1
-13
@@ -1,20 +1,8 @@
|
||||
# Standard ComfyUI extension loader
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import uuid
|
||||
|
||||
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})
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
+518
-154
@@ -1,15 +1,228 @@
|
||||
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 globalKeydownHandler = null;
|
||||
|
||||
// Safe clientX resolver for mouse and touch events
|
||||
export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
}
|
||||
|
||||
const getClientX = (e) => {
|
||||
if (e.touches && e.touches.length > 0) return e.touches[0].clientX;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientX;
|
||||
return e.clientX || 0;
|
||||
};
|
||||
|
||||
const getClientY = (e) => {
|
||||
if (e.touches && e.touches.length > 0) return e.touches[0].clientY;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY;
|
||||
return e.clientY || 0;
|
||||
};
|
||||
|
||||
function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
let togglePlayFn = () => {};
|
||||
|
||||
const overlay = new SidebarOverlay({
|
||||
onDestroy,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
togglePlayFn();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const playerBox = document.createElement("div");
|
||||
Object.assign(playerBox.style, {
|
||||
background: "linear-gradient(135deg, #1e1333 0%, #0f172a 100%)",
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "12px",
|
||||
padding: "24px 28px", width: "90%", maxWidth: "520px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.7)", display: "flex",
|
||||
flexDirection: "column", gap: "16px", zIndex: "20"
|
||||
});
|
||||
|
||||
const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav";
|
||||
const parsedExt = (parsedFilename.split(".").pop() || "AUDIO").toUpperCase();
|
||||
|
||||
const titleRow = document.createElement("div");
|
||||
Object.assign(titleRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between" });
|
||||
const title = document.createElement("span");
|
||||
title.textContent = parsedFilename;
|
||||
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f1f5f9", fontFamily: "monospace", maxWidth: "75%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-audio-badge";
|
||||
badge.style.position = "static";
|
||||
badge.textContent = parsedExt;
|
||||
titleRow.append(title, badge);
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
soundwave.style.height = "56px";
|
||||
const barHeights = [14, 22, 38, 28, 48, 36, 20, 42, 54, 32, 18, 40, 50, 30, 22, 36, 48, 24, 18, 34, 46, 26, 16];
|
||||
barHeights.forEach((h, i) => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "comfy-sidebar-soundwave-bar";
|
||||
bar.style.height = `${h}px`;
|
||||
bar.style.width = "4px";
|
||||
bar.style.animationDelay = `${(i * 0.06).toFixed(2)}s`;
|
||||
soundwave.appendChild(bar);
|
||||
});
|
||||
|
||||
const scrubberContainer = document.createElement("div");
|
||||
Object.assign(scrubberContainer.style, {
|
||||
width: "100%", height: "6px", background: "#334155", borderRadius: "3px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "3px" });
|
||||
scrubberContainer.appendChild(scrubberFill);
|
||||
|
||||
const controlsRow = document.createElement("div");
|
||||
Object.assign(controlsRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
|
||||
|
||||
const leftControls = document.createElement("div");
|
||||
Object.assign(leftControls.style, { display: "flex", alignItems: "center", gap: "10px" });
|
||||
|
||||
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
||||
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
Object.assign(playBtn.style, {
|
||||
width: "36px", height: "36px", borderRadius: "50%", background: "#a855f7",
|
||||
border: "none", color: "#fff", cursor: "pointer", display: "flex",
|
||||
alignItems: "center", justifyContent: "center", fontSize: "14px"
|
||||
});
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
|
||||
const timeLabel = document.createElement("span");
|
||||
Object.assign(timeLabel.style, { fontSize: "11px", fontFamily: "monospace", color: "#94a3b8" });
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
leftControls.append(playBtn, timeLabel);
|
||||
|
||||
const rightControls = document.createElement("div");
|
||||
Object.assign(rightControls.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
|
||||
const loopBtn = document.createElement("button");
|
||||
Object.assign(loopBtn.style, {
|
||||
background: "transparent", border: "1px solid #444", color: "#aaa",
|
||||
borderRadius: "4px", padding: "4px 8px", fontSize: "11px", cursor: "pointer"
|
||||
});
|
||||
loopBtn.innerHTML = `<i class="pi pi-replay"></i>`;
|
||||
loopBtn.title = "Toggle Loop";
|
||||
|
||||
const speedSelect = document.createElement("select");
|
||||
Object.assign(speedSelect.style, {
|
||||
background: "#1e293b", border: "1px solid #444", color: "#eee",
|
||||
borderRadius: "4px", padding: "2px 4px", fontSize: "11px", outline: "none"
|
||||
});
|
||||
["0.5x", "1.0x", "1.5x", "2.0x"].forEach(spd => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = spd.replace("x", "");
|
||||
opt.textContent = spd;
|
||||
if (spd === "1.0x") opt.selected = true;
|
||||
speedSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
rightControls.append(loopBtn, speedSelect);
|
||||
controlsRow.append(leftControls, rightControls);
|
||||
|
||||
playerBox.append(titleRow, soundwave, scrubberContainer, controlsRow);
|
||||
overlay.container.appendChild(playerBox);
|
||||
|
||||
const audioEl = document.createElement("audio");
|
||||
audioEl.src = baseSrc;
|
||||
audioEl.preload = "metadata";
|
||||
overlay.container.appendChild(audioEl);
|
||||
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
|
||||
audioEl.onloadedmetadata = () => {
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl.ontimeupdate = () => {
|
||||
if (audioEl.duration > 0) {
|
||||
const percent = (audioEl.currentTime / audioEl.duration) * 100;
|
||||
scrubberFill.style.width = `${percent}%`;
|
||||
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
|
||||
}
|
||||
};
|
||||
|
||||
audioEl.onended = () => {
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
playerBox.classList.remove("playing");
|
||||
scrubberFill.style.width = "0%";
|
||||
};
|
||||
|
||||
togglePlayFn = () => {
|
||||
if (audioEl.paused) {
|
||||
stopAllAudioPlayback();
|
||||
audioEl.play().catch(()=>{});
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
playerBox.classList.add("playing");
|
||||
} else {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
playerBox.classList.remove("playing");
|
||||
scrubberFill.style.width = "0%";
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
}
|
||||
};
|
||||
|
||||
playBtn.onclick = togglePlayFn;
|
||||
loopBtn.onclick = () => {
|
||||
audioEl.loop = !audioEl.loop;
|
||||
loopBtn.style.borderColor = audioEl.loop ? "#a855f7" : "#444";
|
||||
loopBtn.style.color = audioEl.loop ? "#c084fc" : "#aaa";
|
||||
};
|
||||
speedSelect.onchange = (e) => { audioEl.playbackRate = Number(e.target.value); };
|
||||
|
||||
scrubberContainer.onclick = (e) => {
|
||||
const rect = scrubberContainer.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
|
||||
};
|
||||
|
||||
overlay.addCleanup(() => {
|
||||
audioEl.pause();
|
||||
audioEl.src = "";
|
||||
});
|
||||
|
||||
audioEl.play().then(() => {
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
playerBox.classList.add("playing");
|
||||
}).catch(()=>{});
|
||||
|
||||
return {
|
||||
isAudio: true,
|
||||
loadTarget(targetSrc) {
|
||||
if (!isAudioFormat(targetSrc)) {
|
||||
overlay.destroy();
|
||||
onSwitchMedia(targetSrc);
|
||||
return;
|
||||
}
|
||||
const newFilename = getFilenameFromUrl(targetSrc) || "audio.wav";
|
||||
title.textContent = newFilename;
|
||||
badge.textContent = (newFilename.split(".").pop() || "AUDIO").toUpperCase();
|
||||
audioEl.src = targetSrc;
|
||||
audioEl.play().then(() => {
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
playerBox.classList.add("playing");
|
||||
}).catch(()=>{});
|
||||
},
|
||||
destroy: () => overlay.destroy()
|
||||
};
|
||||
}
|
||||
|
||||
const createMediaElement = (src, muted = false) => {
|
||||
const isVideo = isVideoFormat(src);
|
||||
const el = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
@@ -22,7 +235,8 @@ const createMediaElement = (src, muted = false) => {
|
||||
}
|
||||
Object.assign(el.style, {
|
||||
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
|
||||
objectFit: "contain", pointerEvents: "none"
|
||||
width: "auto", height: "auto", objectFit: "contain",
|
||||
pointerEvents: "none", userSelect: "none", webkitUserSelect: "none"
|
||||
});
|
||||
el.src = isVideo ? src + "#t=0.001" : src;
|
||||
return el;
|
||||
@@ -34,9 +248,7 @@ const setupVideoPlayback = (vid, container) => {
|
||||
let videoSyncActive = true;
|
||||
let syncAnimationFrameId = null;
|
||||
|
||||
const onMetaLoaded = () => {
|
||||
vid.play().catch(()=>{});
|
||||
};
|
||||
const onMetaLoaded = () => { vid.play().catch(()=>{}); };
|
||||
vid.addEventListener("loadedmetadata", onMetaLoaded);
|
||||
if (vid.readyState >= 1) onMetaLoaded();
|
||||
|
||||
@@ -71,9 +283,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");
|
||||
@@ -106,7 +316,6 @@ const setupVideoPlayback = (vid, container) => {
|
||||
|
||||
const syncLoop = () => {
|
||||
if (!videoSyncActive) return;
|
||||
|
||||
const cur = vid.currentTime || 0;
|
||||
const dur = vid.duration || 0;
|
||||
if (dur > 0) {
|
||||
@@ -118,7 +327,6 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||
}
|
||||
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
syncAnimationFrameId = requestAnimationFrame(syncLoop);
|
||||
};
|
||||
@@ -136,89 +344,67 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
const canvasEl = document.querySelector("#graph-canvas, canvas");
|
||||
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
|
||||
let splitRatio = 50;
|
||||
let mediaB = null;
|
||||
|
||||
if (targetContainer && targetContainer !== document.body) {
|
||||
targetContainer.style.position = "relative";
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-sidebar-comparison-overlay";
|
||||
Object.assign(container.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
zIndex: "10",
|
||||
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
|
||||
const overlay = new SidebarOverlay({
|
||||
onDestroy,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === " " && mediaB) {
|
||||
e.preventDefault();
|
||||
updateSliderPosition(splitRatio > 50 ? 0 : 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const scrollContainer = document.createElement("div");
|
||||
Object.assign(scrollContainer.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
display: "flex", overflow: "auto", boxSizing: "border-box",
|
||||
padding: "54px 28px 48px 28px", 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%"
|
||||
maxWidth: "85%", maxHeight: "85%", margin: "auto",
|
||||
flexShrink: "0", cursor: isBaseVideo ? "default" : "zoom-in"
|
||||
});
|
||||
container.appendChild(wrapper);
|
||||
scrollContainer.appendChild(wrapper);
|
||||
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", zIndex: "30",
|
||||
color: "#888", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
|
||||
zIndex: "30", color: "#888", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none", background: "rgba(10,10,10,0.75)", padding: "4px 10px",
|
||||
borderRadius: "4px", backdropFilter: "blur(4px)", boxShadow: "0 2px 6px rgba(0,0,0,0.4)"
|
||||
});
|
||||
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#aaa;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
hintPrompt.style.display = "none";
|
||||
}
|
||||
container.appendChild(hintPrompt);
|
||||
|
||||
let wasDragging = false;
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
if (wasDragging) {
|
||||
wasDragging = false;
|
||||
return;
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
if (isBaseVideo) hintPrompt.style.display = "none";
|
||||
overlay.container.appendChild(hintPrompt);
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
|
||||
let mediaB = null;
|
||||
let isZoomed = false;
|
||||
|
||||
const slider = document.createElement("div");
|
||||
Object.assign(slider.style, {
|
||||
@@ -233,17 +419,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}%`;
|
||||
@@ -252,37 +433,6 @@ function createComparisonViewer(baseSrc) {
|
||||
}
|
||||
};
|
||||
|
||||
const startDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
wasDragging = false;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = getClientX(e);
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const doDrag = (e) => {
|
||||
if (!isDragging) return;
|
||||
wasDragging = true;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = getClientX(e);
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
};
|
||||
const endDrag = () => {
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
wrapper.addEventListener("mousedown", startDrag);
|
||||
wrapper.addEventListener("touchstart", startDrag);
|
||||
|
||||
window.addEventListener("mousemove", doDrag);
|
||||
window.addEventListener("touchmove", doDrag);
|
||||
window.addEventListener("mouseup", endDrag);
|
||||
window.addEventListener("touchend", endDrag);
|
||||
|
||||
const cleanupMedia = (el) => {
|
||||
if (el && el.tagName === "VIDEO") {
|
||||
el.pause();
|
||||
@@ -291,72 +441,214 @@ function createComparisonViewer(baseSrc) {
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
const syncImageScales = () => {
|
||||
if (!mediaA) return;
|
||||
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
|
||||
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
|
||||
|
||||
if (!mediaB) {
|
||||
slider.style.display = "none";
|
||||
if (isZoomed && wA && hA) {
|
||||
wrapper.style.maxWidth = "none";
|
||||
wrapper.style.maxHeight = "none";
|
||||
wrapper.style.width = `${wA}px`;
|
||||
wrapper.style.height = `${hA}px`;
|
||||
wrapper.style.cursor = "zoom-out";
|
||||
mediaA.style.maxWidth = "none";
|
||||
mediaA.style.maxHeight = "none";
|
||||
mediaA.style.width = "100%";
|
||||
mediaA.style.height = "100%";
|
||||
mediaA.style.objectFit = "fill";
|
||||
} else {
|
||||
wrapper.style.maxWidth = "85%";
|
||||
wrapper.style.maxHeight = "85%";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
|
||||
mediaA.style.maxWidth = "100%";
|
||||
mediaA.style.maxHeight = "80vh";
|
||||
mediaA.style.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 = "85%";
|
||||
wrapper.style.maxHeight = "85%";
|
||||
wrapper.style.width = "auto";
|
||||
wrapper.style.height = "auto";
|
||||
wrapper.style.aspectRatio = `${maxW} / ${maxH}`;
|
||||
wrapper.style.cursor = "zoom-in";
|
||||
|
||||
[mediaA, mediaB].forEach(el => {
|
||||
if (el) {
|
||||
el.style.maxWidth = "100%";
|
||||
el.style.maxHeight = "80vh";
|
||||
el.style.width = "100%";
|
||||
el.style.height = "100%";
|
||||
el.style.objectFit = "fill";
|
||||
el.style.aspectRatio = `${maxW} / ${maxH}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
mediaA.onload = syncImageScales;
|
||||
if (mediaA.complete) syncImageScales();
|
||||
|
||||
const toggleZoom = () => {
|
||||
if (isBaseVideo) return;
|
||||
isZoomed = !isZoomed;
|
||||
syncImageScales();
|
||||
if (!isZoomed) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
scrollContainer.scrollLeft = 0;
|
||||
}
|
||||
};
|
||||
|
||||
let isDraggingSlider = false, dragMoved = false, dragStartX = 0, dragStartY = 0;
|
||||
|
||||
const startDrag = (e) => {
|
||||
if (e.button && e.button !== 0) return;
|
||||
dragStartX = getClientX(e);
|
||||
dragStartY = getClientY(e);
|
||||
dragMoved = false;
|
||||
isDraggingSlider = true;
|
||||
};
|
||||
|
||||
const doDrag = (e) => {
|
||||
if (!isDraggingSlider) return;
|
||||
const clientX = getClientX(e);
|
||||
const clientY = getClientY(e);
|
||||
if (!dragMoved && (Math.abs(clientX - dragStartX) > 4 || Math.abs(clientY - dragStartY) > 4)) {
|
||||
dragMoved = true;
|
||||
}
|
||||
if (dragMoved && mediaB) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
updateSliderPosition(percent);
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!isDraggingSlider) return;
|
||||
isDraggingSlider = false;
|
||||
if (dragMoved) setTimeout(() => { dragMoved = false; }, 50);
|
||||
};
|
||||
|
||||
wrapper.addEventListener("mousedown", startDrag);
|
||||
wrapper.addEventListener("touchstart", startDrag);
|
||||
window.addEventListener("mousemove", doDrag);
|
||||
window.addEventListener("touchmove", doDrag);
|
||||
window.addEventListener("mouseup", endDrag);
|
||||
window.addEventListener("touchend", endDrag);
|
||||
|
||||
overlay.addCleanup(() => {
|
||||
window.removeEventListener("mousemove", doDrag);
|
||||
window.removeEventListener("touchmove", doDrag);
|
||||
window.removeEventListener("mouseup", endDrag);
|
||||
window.removeEventListener("touchend", endDrag);
|
||||
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (destroyVideoPlaybackFn) destroyVideoPlaybackFn();
|
||||
});
|
||||
|
||||
cleanupMedia(mediaA);
|
||||
cleanupMedia(mediaB);
|
||||
wrapper.addEventListener("click", () => {
|
||||
if (dragMoved || isBaseVideo) return;
|
||||
toggleZoom();
|
||||
});
|
||||
|
||||
container.remove();
|
||||
activeComparisonViewer = null;
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
|
||||
const handleKeys = (e) => {
|
||||
if (e.key === "Escape") destroy();
|
||||
if (e.key === " " && mediaB) {
|
||||
e.preventDefault();
|
||||
updateSliderPosition(splitRatio > 50 ? 0 : 100);
|
||||
}
|
||||
};
|
||||
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
targetContainer.appendChild(container);
|
||||
scrollContainer.addEventListener("click", (e) => {
|
||||
if (dragMoved) return;
|
||||
if (e.target === scrollContainer || e.target === overlay.container) overlay.destroy();
|
||||
});
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
const destroyVideoPlayback = setupVideoPlayback(mediaA, overlay.container);
|
||||
overlay.addCleanup(destroyVideoPlayback);
|
||||
}
|
||||
|
||||
overlay.addCleanup(() => {
|
||||
cleanupMedia(mediaA);
|
||||
cleanupMedia(mediaB);
|
||||
});
|
||||
|
||||
return {
|
||||
is3D: false,
|
||||
isAudio: false,
|
||||
isText: false,
|
||||
loadTarget(targetSrc, isShiftClick) {
|
||||
const isTargetVideo = isVideoFormat(targetSrc);
|
||||
const isTarget3D = is3DFormat(targetSrc);
|
||||
const isTargetAudio = isAudioFormat(targetSrc);
|
||||
const isTargetText = typeof targetSrc === "object" && targetSrc.text;
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroy();
|
||||
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
|
||||
overlay.destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isShiftClick) {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
|
||||
mediaB = createMediaElement(targetSrc, false);
|
||||
mediaB.style.pointerEvents = "none";
|
||||
mediaB.onload = syncImageScales;
|
||||
wrapper.appendChild(mediaB);
|
||||
|
||||
slider.style.display = "block";
|
||||
infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close.";
|
||||
updateSliderPosition(50);
|
||||
if (mediaB.complete && mediaB.naturalWidth) syncImageScales();
|
||||
} else {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
@@ -365,19 +657,91 @@ function createComparisonViewer(baseSrc) {
|
||||
}
|
||||
slider.style.display = "none";
|
||||
mediaA.src = targetSrc;
|
||||
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
mediaA.onload = () => {
|
||||
infoText.style.color = "#aaa";
|
||||
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
|
||||
syncImageScales();
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy: () => overlay.destroy()
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
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
+133
-91
@@ -1,7 +1,21 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { State } from "./state.js";
|
||||
|
||||
async function uploadDroppedImageToInput(imageObj) {
|
||||
async function promoteOrUploadToInput(imageObj) {
|
||||
// 1. Try server-side promotion first (no browser RAM/re-upload overhead)
|
||||
try {
|
||||
const promoteRes = await fetch("/comfy-sidebar/promote-output", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(imageObj)
|
||||
});
|
||||
if (promoteRes.ok) {
|
||||
const data = await promoteRes.json();
|
||||
if (data.name) return data.name;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Fallback to browser blob pipeline if backend promotion endpoint is unavailable
|
||||
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
@@ -22,13 +36,114 @@ async function uploadDroppedImageToInput(imageObj) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorkflowDrop(e) {
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
if (!jsonStr) return false;
|
||||
try {
|
||||
const workflow = JSON.parse(jsonStr);
|
||||
if (workflow && workflow.nodes) {
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(workflow);
|
||||
} else if (app.handleFile) {
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleMediaDrop(e) {
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname !== "/view") return false;
|
||||
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
if (!filename) return false;
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return false;
|
||||
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else if (canvas.canvas) {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || w.name === "audio" || w.name === "audio_file" ||
|
||||
w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" ||
|
||||
w.type === "image" || w.type === "customtext"
|
||||
);
|
||||
|
||||
const isLoadNode = targetNode && (
|
||||
widget ||
|
||||
targetNode.type?.includes("LoadImage") ||
|
||||
targetNode.type?.includes("LoadVideo") ||
|
||||
targetNode.type?.includes("VHS_LoadVideo") ||
|
||||
targetNode.type?.includes("Load3D") ||
|
||||
targetNode.type?.includes("Load 3D") ||
|
||||
targetNode.type?.includes("LoadAudio") ||
|
||||
targetNode.type?.includes("Load Audio") ||
|
||||
targetNode.type?.includes("VHS_LoadAudio") ||
|
||||
targetNode.type?.includes("Preview3D")
|
||||
);
|
||||
|
||||
if (isLoadNode) {
|
||||
const targetWidget = widget || targetNode.widgets?.[0];
|
||||
if (targetWidget) {
|
||||
const newFilename = await promoteOrUploadToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
|
||||
if (!targetWidget.options.values.includes(newFilename)) {
|
||||
targetWidget.options.values.push(newFilename);
|
||||
}
|
||||
}
|
||||
targetWidget.value = newFilename;
|
||||
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const dropHandlers = [
|
||||
handleWorkflowDrop,
|
||||
handleMediaDrop
|
||||
];
|
||||
|
||||
export function setupDragAndDrop() {
|
||||
document.addEventListener("dragover", (e) => {
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("drop", async (e) => {
|
||||
const onDrop = async (e) => {
|
||||
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
|
||||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
|
||||
if (isSidebarDrop) {
|
||||
@@ -37,93 +152,20 @@ export function setupDragAndDrop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return;
|
||||
|
||||
// 1. Check for workflow JSON drop (text-only and unfinished cards)
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const workflow = JSON.parse(jsonStr);
|
||||
if (workflow && workflow.nodes) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(workflow);
|
||||
} else if (app.handleFile) {
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// 2. Check for image / video URL drop
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname === "/view") {
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
|
||||
if (filename) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else if (canvas.canvas) {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
// Check if dropped on a LoadImage, LoadVideo, VHS_LoadVideo, or image/video widget node
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || w.name === "file" || w.name === "video_path" || w.type === "image" || w.type === "customtext"
|
||||
);
|
||||
|
||||
const isLoadNode = targetNode && (widget || targetNode.type?.includes("LoadImage") || targetNode.type?.includes("LoadVideo") || targetNode.type?.includes("VHS_LoadVideo"));
|
||||
|
||||
if (isLoadNode) {
|
||||
const targetWidget = widget || targetNode.widgets?.[0];
|
||||
if (targetWidget) {
|
||||
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
// Populate dropdown values list so combo widgets (like VHS_LoadVideo) accept the new video immediately!
|
||||
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
|
||||
if (!targetWidget.options.values.includes(newFilename)) {
|
||||
targetWidget.options.values.push(newFilename);
|
||||
}
|
||||
}
|
||||
targetWidget.value = newFilename;
|
||||
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas area: fetch file and load embedded workflow metadata
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
for (const handler of dropHandlers) {
|
||||
if (await handler(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", onDragOver);
|
||||
document.addEventListener("drop", onDrop, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", onDragOver);
|
||||
document.removeEventListener("drop", onDrop, true);
|
||||
};
|
||||
}
|
||||
+59
-62
@@ -3,17 +3,26 @@ 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;
|
||||
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
@@ -23,7 +32,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Style overrides for the new native Extensions button container when embedded in top actionbar */
|
||||
.comfy-sidebar-extensions-override {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -37,7 +45,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Scale down performance indicators (Crystools) uniformly */
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
@@ -46,7 +53,6 @@ const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Hide user account profile button and PrimeVue components */
|
||||
img[alt*="User Avatar"],
|
||||
img[alt*="user avatar"],
|
||||
button:has(img[alt*="User Avatar"]),
|
||||
@@ -62,14 +68,12 @@ button:has([data-pc-name="avatar"]),
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Base rules: Large/Wide desktop screens (>= 1600px width) */
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 150px !important;
|
||||
padding-right: 580px !important;
|
||||
}
|
||||
|
||||
/* Medium desktop screens (between 1200px and 1599px width) */
|
||||
@media (max-width: 1599px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -78,7 +82,6 @@ button:has([data-pc-name="avatar"]),
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -87,7 +90,6 @@ button:has([data-pc-name="avatar"]),
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
@@ -98,45 +100,21 @@ button:has([data-pc-name="avatar"]),
|
||||
`;
|
||||
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
if (!app.ui?.settings) return;
|
||||
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
|
||||
try {
|
||||
app.ui.settings.setSettingValue(QPOV2_ID, dockedVal);
|
||||
localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const qpovContainer = document.querySelector('[data-setting-id="Comfy.Queue.QPOV2"]') ||
|
||||
document.getElementById(QPOV2_ID)?.closest('.setting-item, tr, div');
|
||||
if (qpovContainer) {
|
||||
const toggle = qpovContainer.querySelector('input[type="checkbox"], [role="switch"], .p-toggleswitch');
|
||||
if (toggle) {
|
||||
const isChecked = toggle.checked ||
|
||||
toggle.getAttribute('data-p-checked') === 'true' ||
|
||||
toggle.classList.contains('p-toggleswitch-checked');
|
||||
if (isChecked !== dockedVal) toggle.click();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
try {
|
||||
app.ui.settings.setSettingValue(progressKey, progressVal);
|
||||
localStorage.setItem(progressKey, JSON.stringify(progressVal));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const progressBarBtn = document.querySelector('[data-testid="show-run-progress-bar-action"]');
|
||||
if (progressBarBtn) {
|
||||
const isChecked = !!progressBarBtn.querySelector('[class*="lucide--check"], .icon-\\[lucide--check\\]');
|
||||
if (isChecked !== progressVal) progressBarBtn.click();
|
||||
}
|
||||
} catch (e) {}
|
||||
if (app.extensionManager?.setting) {
|
||||
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
|
||||
} else if (app.ui?.settings) {
|
||||
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
|
||||
}
|
||||
|
||||
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
|
||||
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
|
||||
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
|
||||
}
|
||||
|
||||
@@ -144,8 +122,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (updateSetting && app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
}
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
@@ -155,8 +137,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
if (updateSetting && app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +152,11 @@ let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
|
||||
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
@@ -205,15 +196,11 @@ function findTopbarContainer() {
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
|
||||
}
|
||||
|
||||
const origBtn = findOriginalButton();
|
||||
@@ -225,7 +212,6 @@ function isPropertiesPanelOpen() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -257,7 +243,7 @@ function updateSidebarTabsVisibility() {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"]',
|
||||
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
@@ -288,18 +274,18 @@ function updateSidebarTabsVisibility() {
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -551,4 +537,15 @@ export function setupPropertiesPanelToggleFix() {
|
||||
});
|
||||
|
||||
syncClassicLayout();
|
||||
}
|
||||
|
||||
export function destroyLayoutFix() {
|
||||
if (domObserver) {
|
||||
domObserver.disconnect();
|
||||
domObserver = null;
|
||||
}
|
||||
const styleEl = document.getElementById(STYLE_ID);
|
||||
if (styleEl) styleEl.remove();
|
||||
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
|
||||
if (fixStyles) fixStyles.remove();
|
||||
}
|
||||
+101
@@ -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();
|
||||
}
|
||||
}
|
||||
+144
-84
@@ -1,7 +1,7 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } 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,6 +13,7 @@ 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();
|
||||
@@ -26,7 +27,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 +38,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 {
|
||||
@@ -65,10 +66,11 @@ 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){}
|
||||
@@ -85,26 +87,40 @@ const concludeRun = async (pid, statusStr) => {
|
||||
st.endTime = Date.now();
|
||||
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/history/${pid}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[pid]) {
|
||||
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
|
||||
st.nodeOutputs = hItem[pid].outputs;
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow);
|
||||
st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow);
|
||||
}
|
||||
} catch (err) {}
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[key]) {
|
||||
const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null;
|
||||
st.workflow = parseWorkflow(rawWf) || st.workflow;
|
||||
st.nodeOutputs = hItem[key].outputs;
|
||||
|
||||
const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow);
|
||||
if (primaryImgs.length > 0) st.images = primaryImgs;
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow);
|
||||
if (fetchedTexts.length > 0) st.texts = fetchedTexts;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
pruneHistory(app);
|
||||
saveStatesToLocalStorage(); // Persist history state only when a job concludes
|
||||
scheduleStateSave();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
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()) {
|
||||
@@ -115,11 +131,11 @@ export function setupApiListeners() {
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
|
||||
saveStatesToLocalStorage();
|
||||
scheduleStateSave();
|
||||
}
|
||||
}
|
||||
|
||||
const pid = e.detail.prompt_id;
|
||||
const pid = String(e.detail.prompt_id);
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
|
||||
@@ -128,24 +144,23 @@ export function setupApiListeners() {
|
||||
st.status = "active"; st.progressText = "Sampling..."; 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: "Sampling...", timestamp: State.sequenceNumber,
|
||||
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
|
||||
});
|
||||
}
|
||||
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)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
|
||||
// Direct fast DOM update for the progress bar without triggering a full history re-sort
|
||||
const cardObj = cardElements.get(pid);
|
||||
if (cardObj && cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${st.progress}%`;
|
||||
@@ -157,9 +172,9 @@ export function setupApiListeners() {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("executing", (e) => {
|
||||
const onExecuting = (e) => {
|
||||
const nodeId = e.detail;
|
||||
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
|
||||
@@ -173,32 +188,32 @@ export function setupApiListeners() {
|
||||
}
|
||||
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];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
// Retain old blob URL so ui.js can revoke it ONLY AFTER the image element finishes loading
|
||||
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) {
|
||||
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) {
|
||||
st.texts = finalTexts;
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
st.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
@@ -206,53 +221,98 @@ export function setupApiListeners() {
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("execution_success", (e) => concludeRun(e.detail.prompt_id, "completed"));
|
||||
api.addEventListener("execution_error", (e) => concludeRun(e.detail.prompt_id, "error"));
|
||||
api.addEventListener("execution_interrupted", () => {
|
||||
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, "completed");
|
||||
const onExecutionError = (e) => concludeRun(e.detail.prompt_id, "error");
|
||||
const onExecutionInterrupted = () => {
|
||||
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled"));
|
||||
syncQueue();
|
||||
});
|
||||
};
|
||||
|
||||
api.addEventListener("status", onStatus);
|
||||
api.addEventListener("reconnected", onReconnected);
|
||||
api.addEventListener("execution_start", onExecutionStart);
|
||||
api.addEventListener("progress", onProgress);
|
||||
api.addEventListener("executing", onExecuting);
|
||||
api.addEventListener("b_preview", onBPreview);
|
||||
api.addEventListener("executed", onExecuted);
|
||||
api.addEventListener("execution_success", onExecutionSuccess);
|
||||
api.addEventListener("execution_error", onExecutionError);
|
||||
api.addEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
|
||||
// Return cleanup function to unhook listeners when destroyed
|
||||
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) {
|
||||
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,
|
||||
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 }) => {
|
||||
if (promptStates.has(pid)) return;
|
||||
|
||||
const extraData = data.extra_data || data.prompt?.[3] || {};
|
||||
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
const outputs = data.outputs || {};
|
||||
|
||||
const images = getPrimaryOutputImages(outputs, workflow);
|
||||
const texts = findTextsInOutputs(outputs, workflow);
|
||||
|
||||
const statusStr = data.status?.status_str || "completed";
|
||||
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
|
||||
(statusStr === "error" ? "error" : "cancelled");
|
||||
|
||||
if (images.length === 0 && texts.length === 0 && status === "completed") 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);
|
||||
}
|
||||
}
|
||||
+118
-34
@@ -1,13 +1,15 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { injectStyles } from "./styles.js";
|
||||
import { setupDragAndDrop } from "./dragdrop.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM, setupScrollListener } from "./ui.js";
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings } from "./layout.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./layout.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
let cleanupFns = [];
|
||||
|
||||
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
@@ -24,7 +26,11 @@ function findHeaderByText(parent, text) {
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
|
||||
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);
|
||||
@@ -92,6 +98,40 @@ export function syncAllNodeBadges() {
|
||||
});
|
||||
}
|
||||
|
||||
function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
@@ -134,7 +174,6 @@ app.registerExtension({
|
||||
|
||||
init() {
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
@@ -188,6 +227,33 @@ app.registerExtension({
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
@@ -207,51 +273,48 @@ app.registerExtension({
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
injectStyles();
|
||||
setupDragAndDrop();
|
||||
|
||||
// Register disposable subsystems
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupScrollListener());
|
||||
cleanupFns.push(setupApiListeners());
|
||||
|
||||
const sidebarContainer = setupSidebarUI();
|
||||
|
||||
setupApiListeners();
|
||||
|
||||
await initSessionAndHistory();
|
||||
|
||||
// Keyboard shortcuts listener
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Event hooks for node additions and workflow loading (eliminates continuous polling loops)
|
||||
if (app.graph) {
|
||||
const originalConfigure = app.graph.configure;
|
||||
app.graph.configure = function() {
|
||||
const res = originalConfigure ? originalConfigure.apply(this, arguments) : undefined;
|
||||
requestAnimationFrame(() => syncAllNodeBadges());
|
||||
return res;
|
||||
};
|
||||
};
|
||||
|
||||
const originalOnNodeAdded = app.graph.onNodeAdded;
|
||||
app.graph.onNodeAdded = function(node) {
|
||||
if (originalOnNodeAdded) originalOnNodeAdded.apply(this, arguments);
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncNodeVueBadge(node, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
// Apply sidebar override when initializing
|
||||
applySidebarOverride();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
@@ -262,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");
|
||||
}
|
||||
}
|
||||
});
|
||||
+33
-33
@@ -3,20 +3,27 @@ export const cardElements = new Map();
|
||||
|
||||
export const State = {
|
||||
currentSearchQuery: "",
|
||||
globalOrderCounter: 0,
|
||||
sequenceNumber: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null, // Main queue scroll container
|
||||
submenuStack: null, // Submenu scroll container
|
||||
cardStack: null,
|
||||
submenuStack: null,
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
|
||||
activeSubmenuBatchImages: null // Active batch images explorer pointer
|
||||
activeSubmenuPromptId: null,
|
||||
activeSubmenuBatchImages: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely removes a prompt state from memory and revokes any active preview blob URLs.
|
||||
*/
|
||||
let saveTimer = null;
|
||||
export function scheduleStateSave(delay = 250) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
saveStatesToLocalStorage();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export function deletePromptState(pid) {
|
||||
const state = promptStates.get(pid);
|
||||
const key = String(pid);
|
||||
const state = promptStates.get(key);
|
||||
if (state) {
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
@@ -25,19 +32,16 @@ export function deletePromptState(pid) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
}
|
||||
}
|
||||
promptStates.delete(pid);
|
||||
cardElements.delete(pid);
|
||||
promptStates.delete(key);
|
||||
cardElements.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes completed/cancelled history when total items exceed user setting.
|
||||
*/
|
||||
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;
|
||||
@@ -48,10 +52,6 @@ export function pruneHistory(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves all prompt states (including workflows, text outputs, video runs, and failed tasks) to localStorage.
|
||||
* Call ONLY on job completion, cancellation, error, or card deletion.
|
||||
*/
|
||||
export function saveStatesToLocalStorage() {
|
||||
try {
|
||||
let serializable = [];
|
||||
@@ -61,13 +61,15 @@ export function saveStatesToLocalStorage() {
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
|
||||
|
||||
serializable.push({
|
||||
pid: state.pid,
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
workflow: state.workflow,
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
@@ -80,6 +82,11 @@ export function saveStatesToLocalStorage() {
|
||||
});
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
@@ -90,32 +97,25 @@ export function saveStatesToLocalStorage() {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Error preparing localStorage data", e);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cached card states on extension initialization.
|
||||
*/
|
||||
export function loadStatesFromLocalStorage() {
|
||||
try {
|
||||
const data = localStorage.getItem("comfy_sidebar_prompt_states");
|
||||
if (data) {
|
||||
const list = JSON.parse(data);
|
||||
list.forEach(state => {
|
||||
promptStates.set(state.pid, state);
|
||||
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
|
||||
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) {}
|
||||
}
|
||||
|
||||
loadStatesFromLocalStorage();
|
||||
+275
-4
@@ -29,11 +29,19 @@ 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;
|
||||
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
|
||||
padding: 2px 4px; border-radius: 3px; pointerEvents: none; z-index: 5; color: #fff;
|
||||
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
@@ -56,16 +64,27 @@ export function injectStyles() {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Hover Panels CSS Hover Control for Completed / Cancelled / Error cards */
|
||||
.comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.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: 32px !important;
|
||||
height: 32px !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 14px !important;
|
||||
font-size: 13px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
@@ -86,6 +105,258 @@ export function injectStyles() {
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Header Icons & Header Action Buttons */
|
||||
.comfy-sidebar-header-btn {
|
||||
background: transparent;
|
||||
color: var(--desc-color, #aaa);
|
||||
border: 1px solid var(--border-color, #555);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-header-btn:hover {
|
||||
border-color: var(--fg-color, #eee);
|
||||
color: var(--fg-color, #eee);
|
||||
}
|
||||
|
||||
.comfy-sidebar-icon-btn {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Scroll To Top Floating Button */
|
||||
.comfy-sidebar-scroll-top-btn {
|
||||
position: absolute;
|
||||
bottom: 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); }
|
||||
}
|
||||
|
||||
/* Text Output Card Clamping */
|
||||
.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()
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
|
||||
import { isVideoFormat, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { showFullscreenPreview } from "./comparison.js";
|
||||
import { State, promptStates, cardElements, scheduleStateSave, deletePromptState } from "./state.js";
|
||||
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js";
|
||||
|
||||
export let syncQueueFn = async () => {};
|
||||
export function setSyncQueue(fn) { syncQueueFn = fn; }
|
||||
|
||||
let currentlyPlayingAudio = null;
|
||||
export function stopAllAudioPlayback() {
|
||||
if (currentlyPlayingAudio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
currentlyPlayingAudio.currentTime = 0;
|
||||
if (currentlyPlayingAudio._onResetUI) currentlyPlayingAudio._onResetUI();
|
||||
currentlyPlayingAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardElements.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
@@ -43,11 +53,18 @@ function updateScrollTopBtnVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("scroll", () => {
|
||||
const scrollListener = () => {
|
||||
if (State.sidebarContainer && State.sidebarContainer.isConnected) {
|
||||
updateScrollTopBtnVisibility();
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
};
|
||||
|
||||
export function setupScrollListener() {
|
||||
document.addEventListener("scroll", scrollListener, { capture: true, passive: true });
|
||||
return () => {
|
||||
document.removeEventListener("scroll", scrollListener, { capture: true, passive: true });
|
||||
};
|
||||
}
|
||||
|
||||
function findNodeIdForImage(state, img) {
|
||||
if (!state || !state.nodeOutputs || !img) return null;
|
||||
@@ -162,11 +179,8 @@ export function setupSidebarUI() {
|
||||
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
|
||||
const searchIcon = document.createElement("span");
|
||||
searchIcon.className = "pi pi-search";
|
||||
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
|
||||
searchIcon.title = "Search History";
|
||||
Object.assign(searchIcon.style, { cursor: "pointer", fontSize: "13px", opacity: "0.6", transition: "opacity 0.15s ease-in-out" });
|
||||
searchIcon.onmouseenter = () => searchIcon.style.opacity = "1";
|
||||
searchIcon.onmouseleave = () => searchIcon.style.opacity = "0.6";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "Queue";
|
||||
@@ -181,12 +195,8 @@ export function setupSidebarUI() {
|
||||
|
||||
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = iconClass;
|
||||
btn.className = `${iconClass} comfy-sidebar-header-btn`;
|
||||
btn.title = tooltip;
|
||||
Object.assign(btn.style, {
|
||||
background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)",
|
||||
borderRadius: "3px", padding: "4px 8px", cursor: "pointer", fontSize: "13px", transition: "all 0.15s ease-in-out"
|
||||
});
|
||||
|
||||
let timeout = null, isPending = false;
|
||||
const reset = () => {
|
||||
@@ -195,10 +205,7 @@ export function setupSidebarUI() {
|
||||
if (timeout) { clearTimeout(timeout); timeout = null; }
|
||||
};
|
||||
|
||||
btn.onmouseenter = () => { if (!isPending) { btn.style.borderColor = "var(--fg-color, #eee)"; btn.style.color = "var(--fg-color, #eee)"; } };
|
||||
btn.onmouseleave = () => { if (!isPending) { btn.style.borderColor = "var(--border-color, #555)"; btn.style.color = "var(--desc-color, #aaa)"; } };
|
||||
|
||||
btn.onclick = async (ev) => {
|
||||
btn.addEventListener("click", async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!isPending) {
|
||||
isPending = true;
|
||||
@@ -208,7 +215,7 @@ export function setupSidebarUI() {
|
||||
reset();
|
||||
await onClickFn();
|
||||
}
|
||||
};
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
@@ -222,7 +229,7 @@ export function setupSidebarUI() {
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }); } catch (err) {}
|
||||
saveStatesToLocalStorage();
|
||||
scheduleStateSave();
|
||||
renderDOM();
|
||||
}
|
||||
});
|
||||
@@ -234,7 +241,7 @@ export function setupSidebarUI() {
|
||||
}
|
||||
}
|
||||
try { await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ clear: true }) }); } catch (err) {}
|
||||
saveStatesToLocalStorage();
|
||||
scheduleStateSave();
|
||||
renderDOM();
|
||||
});
|
||||
|
||||
@@ -251,59 +258,38 @@ export function setupSidebarUI() {
|
||||
Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." });
|
||||
Object.assign(searchInput.style, { flex: "1", background: "transparent", border: "none", outline: "none", color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0" });
|
||||
const clearSearchBtn = document.createElement("span");
|
||||
clearSearchBtn.className = "pi pi-times";
|
||||
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
|
||||
clearSearchBtn.title = "Clear & Close Search";
|
||||
Object.assign(clearSearchBtn.style, { cursor: "pointer", fontSize: "11px", opacity: "0.6", marginLeft: "6px", transition: "opacity 0.15s ease" });
|
||||
clearSearchBtn.onmouseenter = () => clearSearchBtn.style.opacity = "1"; clearSearchBtn.onmouseleave = () => clearSearchBtn.style.opacity = "0.6";
|
||||
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
|
||||
|
||||
searchContainer.appendChild(searchInputIcon); searchContainer.appendChild(searchInput); searchContainer.appendChild(clearSearchBtn);
|
||||
header.appendChild(standardHeader); header.appendChild(searchContainer); State.sidebarContainer.appendChild(header);
|
||||
|
||||
searchIcon.onclick = (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); };
|
||||
searchIcon.addEventListener("click", (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); });
|
||||
const closeSearch = () => { searchInput.value = ""; State.currentSearchQuery = ""; searchContainer.style.display = "none"; standardHeader.style.display = "flex"; renderDOM(); };
|
||||
clearSearchBtn.onclick = (e) => { e.stopPropagation(); closeSearch(); };
|
||||
searchInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); };
|
||||
searchInput.oninput = () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); };
|
||||
clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); });
|
||||
searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); });
|
||||
searchInput.addEventListener("input", () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); });
|
||||
|
||||
State.cardStack = document.createElement("div");
|
||||
Object.assign(State.cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" });
|
||||
State.sidebarContainer.appendChild(State.cardStack);
|
||||
|
||||
scrollToTopBtnEl = document.createElement("button");
|
||||
scrollToTopBtnEl.className = "pi pi-chevron-up";
|
||||
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
|
||||
scrollToTopBtnEl.title = "Scroll to Top";
|
||||
Object.assign(scrollToTopBtnEl.style, {
|
||||
position: "absolute", bottom: "12px", right: "12px", width: "32px", height: "32px",
|
||||
borderRadius: "50%", background: "rgba(30, 30, 30, 0.9)", color: "#eee",
|
||||
border: "1px solid var(--border-color, #555)", cursor: "pointer", fontSize: "13px",
|
||||
display: "none", alignItems: "center", justifyContent: "center", zIndex: "999",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", transition: "all 0.15s ease-in-out", opacity: "0.85"
|
||||
});
|
||||
|
||||
scrollToTopBtnEl.onmouseenter = () => {
|
||||
scrollToTopBtnEl.style.opacity = "1";
|
||||
scrollToTopBtnEl.style.background = "var(--p-primary-color, #3b82f6)";
|
||||
scrollToTopBtnEl.style.color = "#fff";
|
||||
scrollToTopBtnEl.style.borderColor = "var(--p-primary-color, #3b82f6)";
|
||||
};
|
||||
scrollToTopBtnEl.onmouseleave = () => {
|
||||
scrollToTopBtnEl.style.opacity = "0.85";
|
||||
scrollToTopBtnEl.style.background = "rgba(30, 30, 30, 0.9)";
|
||||
scrollToTopBtnEl.style.color = "#eee";
|
||||
scrollToTopBtnEl.style.borderColor = "var(--border-color, #555)";
|
||||
};
|
||||
|
||||
scrollToTopBtnEl.onclick = (e) => {
|
||||
scrollToTopBtnEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
State.sidebarContainer.appendChild(scrollToTopBtnEl);
|
||||
|
||||
State.sidebarContainer.onclick = (e) => {
|
||||
State.sidebarContainer.addEventListener("click", (e) => {
|
||||
if (State.activeSubmenuPromptId || State.activeSubmenuBatchImages) {
|
||||
if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return;
|
||||
State.activeSubmenuPromptId = null;
|
||||
@@ -313,7 +299,7 @@ export function setupSidebarUI() {
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
new ResizeObserver((entries) => {
|
||||
const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350;
|
||||
@@ -324,7 +310,6 @@ export function setupSidebarUI() {
|
||||
}
|
||||
}).observe(State.sidebarContainer);
|
||||
|
||||
// Light 250ms active timer tick
|
||||
setInterval(() => {
|
||||
if (State.currentlyActivePromptId === null) return;
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
@@ -341,10 +326,15 @@ export function setupSidebarUI() {
|
||||
function syncCardButtonVisibility(cardObj, state) {
|
||||
if (!cardObj) return;
|
||||
|
||||
if (state.status === "active" || state.status === "pending") {
|
||||
// Pending and currently running cards should never display hover action panels
|
||||
const isPendingOrActive = state.status === "pending" || state.status === "active";
|
||||
if (isPendingOrActive) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
} else {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
|
||||
}
|
||||
|
||||
const isCompleted = state.status === "completed";
|
||||
@@ -387,6 +377,11 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnDel) {
|
||||
cardObj.btnDel.style.removeProperty("display");
|
||||
cardObj.btnDel.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
if (cardObj.btnFocus) {
|
||||
const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null;
|
||||
const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null;
|
||||
@@ -425,34 +420,269 @@ function syncCardButtonVisibility(cardObj, state) {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
const hasRightButtons = (cardObj.btnImg && cardObj.btnImg.style.display !== "none") ||
|
||||
(cardObj.btnJson && cardObj.btnJson.style.display !== "none") ||
|
||||
(cardObj.btnDel && cardObj.btnDel.style.display !== "none");
|
||||
|
||||
const hasLeftButtons = (cardObj.btnFocus && cardObj.btnFocus.style.display !== "none") ||
|
||||
(cardObj.leftHoverBtn && cardObj.leftHoverBtn.style.display !== "none");
|
||||
|
||||
if (cardObj.hoverPanel) {
|
||||
if (hasRightButtons) {
|
||||
cardObj.hoverPanel.style.removeProperty("display");
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
} else {
|
||||
cardObj.hoverPanel.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.leftHoverPanel) {
|
||||
if (hasLeftButtons) {
|
||||
cardObj.leftHoverPanel.style.removeProperty("display");
|
||||
cardObj.leftHoverPanel.style.display = "flex";
|
||||
} else {
|
||||
cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardImages(cardObj, state, keepAspect) {
|
||||
function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
|
||||
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(i.filename || i.url) && !isAudioFormat(i.filename || i.url)) || [];
|
||||
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
|
||||
const bgImgSrc = lastImageAsset
|
||||
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
|
||||
: null;
|
||||
|
||||
if (!preview3D) {
|
||||
wrapper.innerHTML = "";
|
||||
preview3D = document.createElement("div");
|
||||
preview3D.className = "comfy-sidebar-3d-wrapper";
|
||||
|
||||
if (bgImgSrc) {
|
||||
const bgImg = document.createElement("img");
|
||||
bgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
bgImg.src = bgImgSrc;
|
||||
bgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.appendChild(bgImg);
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "comfy-sidebar-3d-overlay";
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-3d-badge";
|
||||
const ext = (img.filename || src).split('.').pop().toUpperCase();
|
||||
badge.textContent = ext || "3D";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-box comfy-sidebar-3d-icon";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-3d-title";
|
||||
title.textContent = img.filename || "3D Model";
|
||||
|
||||
overlay.append(badge, icon, title);
|
||||
preview3D.appendChild(overlay);
|
||||
wrapper.appendChild(preview3D);
|
||||
} else {
|
||||
const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img");
|
||||
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
|
||||
bgImg.src = bgImgSrc;
|
||||
} else if (!bgImg && bgImgSrc) {
|
||||
const newBgImg = document.createElement("img");
|
||||
newBgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.insertBefore(newBgImg, preview3D.firstChild);
|
||||
}
|
||||
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
|
||||
if (title) title.textContent = img.filename || "3D Model";
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = preview3D;
|
||||
|
||||
preview3D.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
};
|
||||
|
||||
preview3D.setAttribute("draggable", "true");
|
||||
preview3D.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const filename = img.filename || "model.glb";
|
||||
const mimeType = "application/octet-stream";
|
||||
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullUrl);
|
||||
e.dataTransfer.setData("text/plain", fullUrl);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
}
|
||||
|
||||
function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
|
||||
const ext = filename.split('.').pop().toUpperCase();
|
||||
|
||||
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
||||
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
||||
|
||||
if (!previewAudio) {
|
||||
wrapper.innerHTML = "";
|
||||
previewAudio = document.createElement("div");
|
||||
previewAudio.className = "comfy-sidebar-audio-wrapper";
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-audio-title";
|
||||
title.textContent = filename;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-audio-badge";
|
||||
badge.textContent = ext || "AUDIO";
|
||||
|
||||
topRow.append(title, badge);
|
||||
|
||||
const centerArea = document.createElement("div");
|
||||
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" });
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
soundwave.style.width = "100%";
|
||||
const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12];
|
||||
barHeights.forEach((h, i) => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "comfy-sidebar-soundwave-bar";
|
||||
bar.style.height = `${h}px`;
|
||||
bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`;
|
||||
soundwave.appendChild(bar);
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
Object.assign(playBtn.style, {
|
||||
position: "absolute", width: "36px", height: "36px", borderRadius: "50%",
|
||||
background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5",
|
||||
transition: "all 0.15s ease", outline: "none"
|
||||
});
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
|
||||
centerArea.append(soundwave, playBtn);
|
||||
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" });
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
Object.assign(scrubber.style, {
|
||||
width: "100%", height: "4px", background: "#334155", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" });
|
||||
scrubber.appendChild(scrubberFill);
|
||||
|
||||
const timeLabel = document.createElement("div");
|
||||
Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" });
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
bottomRow.append(scrubber, timeLabel);
|
||||
|
||||
const audioEl = document.createElement("audio");
|
||||
audioEl.src = fullUrl;
|
||||
audioEl.preload = "metadata";
|
||||
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
|
||||
const resetAudioUI = () => {
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
previewAudio.classList.remove("playing");
|
||||
scrubberFill.style.width = "0%";
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl._onResetUI = resetAudioUI;
|
||||
|
||||
audioEl.onloadedmetadata = () => {
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl.ontimeupdate = () => {
|
||||
if (audioEl.duration > 0) {
|
||||
const percent = (audioEl.currentTime / audioEl.duration) * 100;
|
||||
scrubberFill.style.width = `${percent}%`;
|
||||
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
|
||||
}
|
||||
};
|
||||
|
||||
audioEl.onended = () => {
|
||||
resetAudioUI();
|
||||
if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null;
|
||||
};
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isAudioViewerOpen()) {
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioEl.paused) {
|
||||
stopAllAudioPlayback();
|
||||
currentlyPlayingAudio = audioEl;
|
||||
audioEl.play().catch(()=>{});
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
previewAudio.classList.add("playing");
|
||||
} else {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
resetAudioUI();
|
||||
if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null;
|
||||
}
|
||||
};
|
||||
|
||||
playBtn.onclick = togglePlay;
|
||||
|
||||
playBtn.onmouseenter = () => {
|
||||
playBtn.style.background = "#9333ea";
|
||||
playBtn.style.borderColor = "#c084fc";
|
||||
playBtn.style.transform = "scale(1.08)";
|
||||
};
|
||||
playBtn.onmouseleave = () => {
|
||||
playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)";
|
||||
playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)";
|
||||
playBtn.style.transform = "scale(1.0)";
|
||||
};
|
||||
|
||||
scrubber.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
|
||||
};
|
||||
|
||||
previewAudio.append(topRow, centerArea, bottomRow, audioEl);
|
||||
wrapper.appendChild(previewAudio);
|
||||
|
||||
previewAudio.onclick = (e) => {
|
||||
if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return;
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
};
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = previewAudio;
|
||||
|
||||
previewAudio.setAttribute("draggable", "true");
|
||||
previewAudio.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const mimeType = "audio/wav";
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullUrl);
|
||||
e.dataTransfer.setData("text/plain", fullUrl);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
}
|
||||
|
||||
function renderCardImages(cardObj, state) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
@@ -469,6 +699,8 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const isVideo = isVideoFormat(src);
|
||||
const is3D = is3DFormat(src) || is3DFormat(img.filename);
|
||||
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
|
||||
|
||||
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
|
||||
if (!wrapper) {
|
||||
@@ -479,6 +711,16 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
cardObj.grid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
if (is3D) {
|
||||
render3DCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
renderAudioCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaEl = wrapper.querySelector("img, video");
|
||||
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
@@ -503,17 +745,32 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
showFullscreenPreview([src], ev.shiftKey);
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
if (keepAspect && width && height) {
|
||||
mediaEl.style.aspectRatio = `${width} / ${height}`;
|
||||
mediaEl.onerror = async () => {
|
||||
if (src && !src.startsWith("blob:") && !is3D && !isAudio) {
|
||||
try {
|
||||
const res = await fetch(src, { method: "HEAD" });
|
||||
if (res.status === 404) {
|
||||
deletePromptState(state.pid);
|
||||
scheduleStateSave();
|
||||
if (cardObj.element) cardObj.element.remove();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (cardObj.placeholder) {
|
||||
cardObj.placeholder.textContent = "Error loading media preview";
|
||||
cardObj.placeholder.style.display = "block";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
if (cardObj.dimEl && width && height) {
|
||||
cardObj.dimEl.textContent = `${width}x${height}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
const isUnfinished = state.status && state.status !== "completed";
|
||||
const isUnfinished = state.status ? (state.status !== "completed") : false;
|
||||
if (isUnfinished) {
|
||||
mediaEl.setAttribute("draggable", "false");
|
||||
mediaEl.style.cursor = "grab";
|
||||
@@ -535,6 +792,9 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
e.dataTransfer.setData("text/uri-list", fullSrc);
|
||||
e.dataTransfer.setData("text/plain", fullSrc);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.stopPropagation();
|
||||
@@ -588,7 +848,6 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
} else {
|
||||
mediaEl.onload = () => {
|
||||
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
// Safely revoke old preview blob URL ONLY after new frame finishes rendering!
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){}
|
||||
delete state._oldPreviewBlobUrl;
|
||||
@@ -646,7 +905,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
@@ -677,7 +936,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
@@ -701,7 +960,7 @@ const handleGlobalClick = (e) => {
|
||||
}
|
||||
const sidebar = State.sidebarContainer;
|
||||
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"]');
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay');
|
||||
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
@@ -718,14 +977,12 @@ export function renderDOM() {
|
||||
if (renderTimeout) cancelAnimationFrame(renderTimeout);
|
||||
renderTimeout = requestAnimationFrame(() => {
|
||||
const showPendingSummary = app.ui.settings.getSettingValue("Comfy Sidebar.Show Pending Count Only") ?? true;
|
||||
const keepAspect = app.ui.settings.getSettingValue("Comfy Sidebar.Keep Object Aspect Ratio") ?? true;
|
||||
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
|
||||
const headerTitle = State.sidebarContainer.querySelector("h3");
|
||||
const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
|
||||
const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
|
||||
|
||||
// Submenu 1: Batch Submenu View
|
||||
if (State.activeSubmenuBatchImages) {
|
||||
const batchInfo = State.activeSubmenuBatchImages;
|
||||
|
||||
@@ -776,13 +1033,12 @@ export function renderDOM() {
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", right: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -792,8 +1048,9 @@ export function renderDOM() {
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", left: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -809,16 +1066,10 @@ export function renderDOM() {
|
||||
card.addEventListener("mouseenter", () => {
|
||||
syncCardButtonVisibility(cardObj, { status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs });
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
cardObj.placeholder.style.display = "none";
|
||||
|
||||
renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs }, keepAspect);
|
||||
|
||||
renderCardImages(cardObj, { pid: batchInfo.pid, status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs });
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
|
||||
@@ -831,7 +1082,6 @@ export function renderDOM() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Submenu 2: Intermediate Outputs View
|
||||
if (State.activeSubmenuPromptId) {
|
||||
const st = promptStates.get(State.activeSubmenuPromptId);
|
||||
if (!st) {
|
||||
@@ -889,13 +1139,12 @@ export function renderDOM() {
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", right: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -905,8 +1154,9 @@ export function renderDOM() {
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", left: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -922,15 +1172,11 @@ export function renderDOM() {
|
||||
card.addEventListener("mouseenter", () => {
|
||||
syncCardButtonVisibility(cardObj, { status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs });
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
if (out.images && out.images.length > 0) {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs }, keepAspect);
|
||||
renderCardImages(cardObj, { pid: st.pid, status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs });
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "block";
|
||||
cardObj.placeholder.textContent = "No Outputs";
|
||||
@@ -948,7 +1194,6 @@ export function renderDOM() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard Queue Main View
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = "Queue";
|
||||
headerTitle.style.cursor = "default";
|
||||
@@ -989,19 +1234,19 @@ export function renderDOM() {
|
||||
const sBadge = document.createElement("div");
|
||||
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none", zIndex: "10" });
|
||||
const grid = document.createElement("div"); grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none",
|
||||
display: "-webkit-box", WebkitLineClamp: "15", WebkitBoxOrient: "vertical", overflow: "hidden", wordBreak: "break-word"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const pt = document.createElement("div"); Object.assign(pt.style, { width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px", overflow: "hidden", display: "none" });
|
||||
const pb = document.createElement("div"); Object.assign(pb.style, { width: `0%`, height: "100%", background: "#3b82f6", transition: "width 0.1s linear" });
|
||||
pt.appendChild(pb);
|
||||
const statusText = document.createElement("div"); Object.assign(statusText.style, { fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px", display: "none", fontWeight: "bold" });
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", right: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -1010,8 +1255,9 @@ export function renderDOM() {
|
||||
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn"; btnDel.title = "Delete Card";
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
position: "absolute", bottom: "4px", left: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
@@ -1033,10 +1279,6 @@ export function renderDOM() {
|
||||
card.addEventListener("mouseenter", () => {
|
||||
syncCardButtonVisibility(cardObj, state);
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
const isUnfinished = state.status && state.status !== "completed";
|
||||
@@ -1122,27 +1364,42 @@ export function renderDOM() {
|
||||
resetDeleteBtn();
|
||||
promptStates.delete(state.pid);
|
||||
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
|
||||
saveStatesToLocalStorage();
|
||||
scheduleStateSave();
|
||||
renderDOM();
|
||||
}
|
||||
};
|
||||
|
||||
const currentImagesSignature = state.images ? state.images.map(img => img.url || img.filename).join("|") : "";
|
||||
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
|
||||
if (cardObj.lastImagesSignature !== currentImagesSignature) {
|
||||
if (!state.images || state.images.length === 0) {
|
||||
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null; cardObj.placeholder.style.display = "block";
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
|
||||
if (state.images.length === 0) {
|
||||
if (state.texts && state.texts.length > 0) {
|
||||
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left" });
|
||||
const fullText = state.texts.join("\n\n");
|
||||
cardObj.placeholder.textContent = fullText;
|
||||
cardObj.placeholder.title = "Click to read full text";
|
||||
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
|
||||
cardObj.placeholder.style.display = "-webkit-box";
|
||||
cardObj.placeholder.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
|
||||
};
|
||||
} else {
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center" });
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs";
|
||||
cardObj.placeholder.className = "";
|
||||
Object.assign(cardObj.placeholder.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
|
||||
userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default"
|
||||
});
|
||||
cardObj.placeholder.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,7 +1415,9 @@ export function renderDOM() {
|
||||
return cardObj.element;
|
||||
};
|
||||
|
||||
for (const [pid, cardObj] of cardElements.entries()) { if (pid !== "pending-summary-card" && pid !== "pending-cancel-all-standalone" && !promptStates.has(pid)) cardElements.delete(pid); }
|
||||
for (const [pid] of cardElements.entries()) {
|
||||
if (pid !== "pending-summary-card" && pid !== "pending-cancel-all-standalone" && !promptStates.has(pid)) cardElements.delete(pid);
|
||||
}
|
||||
|
||||
const pendingCount = Array.from(promptStates.values()).filter(t => t.status === "pending").length;
|
||||
const targetElements = [];
|
||||
|
||||
+83
-24
@@ -1,44 +1,95 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
const s = url.toLowerCase();
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".mp4") || s.includes(".webm");
|
||||
};
|
||||
|
||||
function isNodeIgnored(nodeId, workflow) {
|
||||
if (!workflow || !workflow.nodes) {
|
||||
// Fallback to active graph if workflow is missing (e.g. during live execution)
|
||||
const liveNode = app.graph?.getNodeById(Number(nodeId));
|
||||
if (liveNode) {
|
||||
return !!(liveNode.properties && liveNode.properties.ignoreInQueue);
|
||||
export const is3DFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".glb") || s.includes(".gltf") || s.includes(".obj") ||
|
||||
s.includes(".ply") || s.includes(".stl") || s.includes(".splat") ||
|
||||
s.includes(".spz") || s.includes(".ksplat") || s.includes(".fbx");
|
||||
};
|
||||
|
||||
export const isAudioFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".wav") || s.includes(".mp3") || s.includes(".ogg") ||
|
||||
s.includes(".flac") || s.includes(".m4a") || s.includes(".aac") ||
|
||||
s.includes(".opus");
|
||||
};
|
||||
|
||||
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) {
|
||||
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 scan = (obj) => {
|
||||
if (!obj) return;
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => scan(item));
|
||||
} else if (typeof obj === "object") {
|
||||
if (obj.filename || obj.name) {
|
||||
const fname = obj.filename || obj.name;
|
||||
if (typeof fname === "string" && (fname.includes(".") || obj.type)) {
|
||||
list.push({
|
||||
filename: fname,
|
||||
subfolder: obj.subfolder || "",
|
||||
type: obj.type || "output"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const key in obj) {
|
||||
scan(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
scan(outputs[nodeId]);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function findTextsInOutputs(outputs, workflow) {
|
||||
export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
for (const nodeId in outputs) {
|
||||
@@ -65,8 +116,8 @@ 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) {
|
||||
@@ -78,6 +129,13 @@ export function getRunOutputs(nodeOutputs, workflow) {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return [];
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return [];
|
||||
return runOutputs[runOutputs.length - 1].images || [];
|
||||
}
|
||||
|
||||
export function matchesFilter(state, query) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -87,8 +145,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()
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.2.9"
|
||||
version = "1.3.2"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user