Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
684726789f | ||
|
|
c2c82065a7 | ||
|
|
5fde92b51a | ||
|
|
c43863a219 | ||
|
|
111de1a8c2 | ||
|
|
92653bfa75 | ||
|
|
bf2cdc4bcd | ||
|
|
d85c54232a |
@@ -13,7 +13,8 @@ A lightweight, high-performance ComfyUI extension that replaces the cluttered st
|
||||
* **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 click another to load it alongside the first one for pixel by pixel comparison.
|
||||
* **[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.
|
||||
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
@@ -32,8 +33,10 @@ Restart your ComfyUI server and refresh the browser tab.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Additional tweaks are seamlessly integrated into the standard ComfyUI Settings panel. From there, you can adjust thresholds or fully replace the stock V2 panel by enabling options like **"Override Stock Job History Tab"**.
|
||||
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"**.
|
||||
|
||||
## ⌨️ Shortcuts
|
||||
|
||||
* **`Q`**: Instantly toggles the sidebar panel open or closed. Safely ignores inputs if you are currently typing inside a text field, input area, or combo box.
|
||||
* **`Ctrl+Q on a selected node`**: Sets the node to be ignored in the sidebar panel results.
|
||||
* **`Click`** on a card to enter preview. **`Shift+Click`** on another card in preview to enter compare mode.
|
||||
+58
-41
@@ -5,6 +5,9 @@ let activeComparisonViewer = null;
|
||||
let videoSyncActive = false;
|
||||
let syncAnimationFrameId = null;
|
||||
|
||||
// Track the global keydown reference across instances defensively
|
||||
let globalKeydownHandler = null;
|
||||
|
||||
const createMediaElement = (src, muted = false) => {
|
||||
const isVideo = isVideoFormat(src);
|
||||
const el = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
@@ -13,7 +16,7 @@ const createMediaElement = (src, muted = false) => {
|
||||
el.playsInline = true;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.controls = false; // Disable native controls to prevent layout shifts
|
||||
el.controls = false;
|
||||
}
|
||||
Object.assign(el.style, {
|
||||
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
|
||||
@@ -23,7 +26,6 @@ const createMediaElement = (src, muted = false) => {
|
||||
return el;
|
||||
};
|
||||
|
||||
// Video Playback Manager: Binds timeline progress bar, timeline scrubbing, and play/pause triggers
|
||||
const setupVideoPlayback = (vid, container) => {
|
||||
if (!vid) return;
|
||||
|
||||
@@ -33,10 +35,9 @@ const setupVideoPlayback = (vid, container) => {
|
||||
vid.addEventListener("loadedmetadata", onMetaLoaded);
|
||||
if (vid.readyState >= 1) onMetaLoaded();
|
||||
|
||||
// Synced Scrubber Progress Bar Overlay
|
||||
const controlBar = document.createElement("div");
|
||||
Object.assign(controlBar.style, {
|
||||
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
|
||||
position: "absolute", bottom: "16px", left: "50%",
|
||||
display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)",
|
||||
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
|
||||
fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px",
|
||||
@@ -102,7 +103,6 @@ const setupVideoPlayback = (vid, container) => {
|
||||
const syncLoop = () => {
|
||||
if (!videoSyncActive) return;
|
||||
|
||||
// Update progress & labels
|
||||
const cur = vid.currentTime || 0;
|
||||
const dur = vid.duration || 0;
|
||||
if (dur > 0) {
|
||||
@@ -115,9 +115,7 @@ const setupVideoPlayback = (vid, container) => {
|
||||
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||
}
|
||||
|
||||
// Dynamically match play button in case video loops
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
|
||||
syncAnimationFrameId = requestAnimationFrame(syncLoop);
|
||||
};
|
||||
requestAnimationFrame(syncLoop);
|
||||
@@ -133,14 +131,16 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
// DEFENSIVE CHECK: Force-remove any globally orphaned keydown handlers before binding a new one
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
// Mount the viewer overlay container directly inside ComfyUI's canvas parent.
|
||||
// This allows menus/sidebars outside the canvas area to naturally render on top.
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
const canvasEl = document.querySelector("#graph-canvas, canvas");
|
||||
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
|
||||
|
||||
// Ensure the canvas container is positioned relatively so our overlay stretches accurately
|
||||
if (targetContainer && targetContainer !== document.body) {
|
||||
targetContainer.style.position = "relative";
|
||||
}
|
||||
@@ -151,7 +151,7 @@ function createComparisonViewer(baseSrc) {
|
||||
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", // Above base LGraph grid but strictly below other UI components
|
||||
zIndex: "10",
|
||||
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
|
||||
});
|
||||
|
||||
@@ -164,11 +164,10 @@ function createComparisonViewer(baseSrc) {
|
||||
const infoText = document.createElement("span");
|
||||
infoText.textContent = isBaseVideo
|
||||
? "Video playback. Press Esc to close."
|
||||
: "Reference loaded. Click another card image in the sidebar to compare.";
|
||||
: "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
header.appendChild(infoText);
|
||||
container.appendChild(header);
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "pi pi-times";
|
||||
closeBtn.title = "Close Comparison (Esc)";
|
||||
@@ -180,7 +179,6 @@ function createComparisonViewer(baseSrc) {
|
||||
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
// Workspace wrapper holding both elements aligned pixel-perfectly
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
@@ -188,21 +186,30 @@ function createComparisonViewer(baseSrc) {
|
||||
});
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Close strictly on clicking empty background space of the overlay backdrop
|
||||
// Muted bottom tooltip helper
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", zIndex: "30",
|
||||
color: "#555", fontSize: "10px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#777;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
hintPrompt.style.display = "none";
|
||||
}
|
||||
container.appendChild(hintPrompt);
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
// Media A (Base reference - Left. Created unmuted to enable audio playback)
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
|
||||
// Media B (Comparison target - Right)
|
||||
let mediaB = null;
|
||||
|
||||
// Draggable Split Slider Handle
|
||||
const slider = document.createElement("div");
|
||||
Object.assign(slider.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "50%",
|
||||
@@ -223,7 +230,7 @@ function createComparisonViewer(baseSrc) {
|
||||
slider.appendChild(sliderButton);
|
||||
wrapper.appendChild(slider);
|
||||
|
||||
let splitRatio = 50; // Percentage
|
||||
let splitRatio = 50;
|
||||
let isDragging = false;
|
||||
let destroyVideoPlaybackFn = null;
|
||||
|
||||
@@ -231,7 +238,6 @@ function createComparisonViewer(baseSrc) {
|
||||
splitRatio = Math.max(0, Math.min(100, percent));
|
||||
slider.style.left = `${splitRatio}%`;
|
||||
if (mediaB) {
|
||||
// Symmetrical split comparison layout: Image A on Left, Image B on Right
|
||||
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
|
||||
}
|
||||
};
|
||||
@@ -241,7 +247,6 @@ function createComparisonViewer(baseSrc) {
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
|
||||
// Symmetrical Click Teleportation: Snaps split-line directly to mouse coordinates
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const percent = ((clientX - rect.left) / rect.width) * 100;
|
||||
@@ -256,7 +261,6 @@ function createComparisonViewer(baseSrc) {
|
||||
};
|
||||
const endDrag = () => { isDragging = false; };
|
||||
|
||||
// Allows dragging/clicking from any region of the wrapper
|
||||
wrapper.addEventListener("mousedown", startDrag);
|
||||
wrapper.addEventListener("touchstart", startDrag);
|
||||
|
||||
@@ -270,7 +274,11 @@ function createComparisonViewer(baseSrc) {
|
||||
window.removeEventListener("touchmove", doDrag);
|
||||
window.removeEventListener("mouseup", endDrag);
|
||||
window.removeEventListener("touchend", endDrag);
|
||||
document.removeEventListener("keydown", handleKeys);
|
||||
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
videoSyncActive = false;
|
||||
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
|
||||
@@ -289,53 +297,62 @@ function createComparisonViewer(baseSrc) {
|
||||
updateSliderPosition(splitRatio > 50 ? 0 : 100);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeys);
|
||||
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
targetContainer.appendChild(container);
|
||||
|
||||
// Initialize single-video playbacks if the base output is an MP4
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
}
|
||||
|
||||
return {
|
||||
loadTarget(targetSrc) {
|
||||
loadTarget(targetSrc, isShiftClick) {
|
||||
const isTargetVideo = isVideoFormat(targetSrc);
|
||||
|
||||
if (isBaseVideo) {
|
||||
// If in Video Playback Mode: clicking another item replaces the player or opens image comparison
|
||||
destroy();
|
||||
showFullscreenPreview([targetSrc]);
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
// If in Image Comparison Mode: ignore video targets ("images compare only to other images")
|
||||
if (isTargetVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaB) mediaB.remove();
|
||||
if (isShiftClick) {
|
||||
// Shift+Click: Sideload target image B for comparison split
|
||||
if (mediaB) mediaB.remove();
|
||||
|
||||
mediaB = createMediaElement(targetSrc, false);
|
||||
mediaB.style.pointerEvents = "none";
|
||||
wrapper.appendChild(mediaB);
|
||||
mediaB = createMediaElement(targetSrc, false);
|
||||
mediaB.style.pointerEvents = "none";
|
||||
wrapper.appendChild(mediaB);
|
||||
|
||||
slider.style.display = "block";
|
||||
infoText.textContent = "Drag the slider to compare. Click other card images to update target | Esc to close.";
|
||||
updateSliderPosition(50);
|
||||
slider.style.display = "block";
|
||||
infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close.";
|
||||
updateSliderPosition(50);
|
||||
} else {
|
||||
// Standard Click: Replace the main base reference image entirely
|
||||
if (mediaB) {
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
mediaA.src = targetSrc;
|
||||
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs) {
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
// If the comparison overlay is already open, dynamically load the target
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(imgSrcs[0]);
|
||||
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, open a fresh side-by-side comparison workspace
|
||||
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
|
||||
}
|
||||
Vendored
+10
@@ -1,4 +1,5 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { State } from "./state.js";
|
||||
|
||||
async function uploadDroppedImageToInput(imageObj) {
|
||||
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
|
||||
@@ -26,6 +27,15 @@ export function setupDragAndDrop() {
|
||||
});
|
||||
|
||||
document.addEventListener("drop", async (e) => {
|
||||
// Cancel loading entirely and prevent bubbling if dropping inside the sidebar area
|
||||
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
|
||||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
|
||||
if (isSidebarDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return;
|
||||
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage } from "./state.js";
|
||||
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage, cardElements } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
|
||||
|
||||
export let renderDOMFn = () => {};
|
||||
@@ -81,8 +81,8 @@ const concludeRun = async (pid, statusStr) => {
|
||||
if (hItem && hItem[pid]) {
|
||||
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
|
||||
st.nodeOutputs = hItem[pid].outputs; // Store full outputs dictionary!
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs);
|
||||
st.texts = findTextsInOutputs(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) {}
|
||||
pruneHistory(app);
|
||||
@@ -165,12 +165,12 @@ export function setupApiListeners() {
|
||||
api.addEventListener("executed", (e) => {
|
||||
if (promptStates.has(e.detail.prompt_id)) {
|
||||
const st = promptStates.get(e.detail.prompt_id);
|
||||
const finalImgs = findImagesInOutputs({ output: e.detail.output });
|
||||
const finalImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalImgs.length > 0) {
|
||||
// Overwrite with latest images to avoid accumulation across sequential nodes
|
||||
st.images = finalImgs;
|
||||
}
|
||||
const finalTexts = findTextsInOutputs({ output: e.detail.output });
|
||||
const finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (finalTexts.length > 0) {
|
||||
// Overwrite with latest texts to avoid clearing previously set text outputs
|
||||
st.texts = finalTexts;
|
||||
@@ -203,26 +203,30 @@ export async function initSessionAndHistory() {
|
||||
}
|
||||
|
||||
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
|
||||
if (backendSessionId && backendSessionId === storedSessionId) {
|
||||
loadStatesFromLocalStorage();
|
||||
} else {
|
||||
if (backendSessionId && backendSessionId !== storedSessionId) {
|
||||
// Session changed (server restarted)! Clear cached memory.
|
||||
promptStates.clear();
|
||||
cardElements.clear();
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
|
||||
} else if (!storedSessionId && backendSessionId) {
|
||||
localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
|
||||
}
|
||||
|
||||
const 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 images = findImagesInOutputs(historyData[id].outputs);
|
||||
const texts = findTextsInOutputs(historyData[id].outputs);
|
||||
const workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null;
|
||||
const images = findImagesInOutputs(historyData[id].outputs, workflow);
|
||||
const texts = findTextsInOutputs(historyData[id].outputs, workflow);
|
||||
if (images.length === 0 && texts.length === 0) return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(id, {
|
||||
pid: id, status: "completed", images, texts,
|
||||
nodeOutputs: historyData[id].outputs, // Store full outputs dictionary!
|
||||
workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null,
|
||||
workflow: workflow,
|
||||
progressText: "", timestamp: State.globalOrderCounter, rendered: true
|
||||
});
|
||||
});
|
||||
|
||||
+132
-4
@@ -6,6 +6,45 @@ import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueu
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout } from "./layout.js";
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
let nodes = [];
|
||||
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
|
||||
nodes = Object.values(canvas.selected_nodes);
|
||||
} else if (canvas.current_active_node) {
|
||||
nodes = [canvas.current_active_node];
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
});
|
||||
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.replace(/^(🚫|👁️⃠)\s*/, "").trim();
|
||||
|
||||
// Grab all potential elements that could hold the title text
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
// Ensure we are selecting a small title element, not the entire node wrapper
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
@@ -44,8 +83,8 @@ app.registerExtension({
|
||||
setTimeout(() => {
|
||||
if (typeof syncClassicLayout === "function") syncClassicLayout();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add the toggle setting for Graph Button under "Hide Junk"
|
||||
app.ui.settings.addSetting({
|
||||
@@ -103,14 +142,103 @@ app.registerExtension({
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
|
||||
if (e.key.toLowerCase() === "q") {
|
||||
|
||||
// Toggle Queue open/close (Q)
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
// Toggle Ignore Node in Queue (Ctrl + Q)
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
}, true);
|
||||
|
||||
setInterval(() => applySidebarOverride(), 500);
|
||||
setInterval(() => {
|
||||
applySidebarOverride();
|
||||
|
||||
// Sync ignoreInQueue visual states across both canvas and Vue DOM modes dynamically
|
||||
if (app.graph && app.graph._nodes) {
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
// 1. Sweep away legacy title emoji residue to keep graph titles perfectly clean
|
||||
if (node.title && (node.title.startsWith("🚫 ") || node.title.startsWith("👁️⃠ "))) {
|
||||
node.title = node.title.replace(/^(🚫|👁️⃠)\s*/, "");
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// 2. Classic LiteGraph Native Glowing Border Sync
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Nodes 2.0 DOM Sibling Support (Vue Mode)
|
||||
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
|
||||
if (nodeEl) {
|
||||
// Dynamically locate the node header container by searching for the node title text element
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
|
||||
if (titleTextNode) {
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
// Custom SVG crossed-out eye
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
// Flows natively inside the parent header block with automatic flex alignment
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit", // Inherits color of parent text (automatically morphs on highlight!)
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback cleanup if the title text element is temporarily unmounted
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 6. Final Registration
|
||||
app.extensionManager.registerSidebarTab({
|
||||
|
||||
+4
-1
@@ -66,4 +66,7 @@ export function loadStatesFromLocalStorage() {
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load instantly and synchronously on script import to prevent race conditions during early renders!
|
||||
loadStatesFromLocalStorage();
|
||||
@@ -8,6 +8,34 @@ import { showFullscreenPreview } from "./comparison.js";
|
||||
export let syncQueueFn = async () => {};
|
||||
export function setSyncQueue(fn) { syncQueueFn = fn; }
|
||||
|
||||
// Resets any stale hover states when transitioning between submenus and the main queue
|
||||
function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardElements.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Traverses node execution metadata to resolve the exact node ID that generated a specific image
|
||||
function findNodeIdForImage(state, img) {
|
||||
if (!state || !state.nodeOutputs || !img) return null;
|
||||
for (const nodeId in state.nodeOutputs) {
|
||||
const out = state.nodeOutputs[nodeId];
|
||||
// Safely check both gifs, images, or videos under this specific node
|
||||
for (const key in out) {
|
||||
const val = out[key];
|
||||
if (Array.isArray(val)) {
|
||||
if (val.some(i => i && typeof i === 'object' && i.filename === img.filename)) {
|
||||
return nodeId;
|
||||
}
|
||||
} else if (val && typeof val === 'object' && val.filename === img.filename) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateSidebarBadge = (count) => {
|
||||
const icons = document.querySelectorAll('.pi-images');
|
||||
icons.forEach(icon => {
|
||||
@@ -216,6 +244,7 @@ export function setupSidebarUI() {
|
||||
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;
|
||||
State.activeSubmenuBatchImages = null;
|
||||
resetAllCardHoverStates(); // Instantly wipe stale hover indicators on exit click
|
||||
renderDOM();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
@@ -253,28 +282,102 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
if (!img) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
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 currentMediaEl = cardObj.grid.querySelector("img, video");
|
||||
const matchesTagType = currentMediaEl && (isVideo === (currentMediaEl.tagName.toLowerCase() === "video"));
|
||||
// Find or create the native wrapper
|
||||
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
|
||||
if (!wrapper) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "comfy-sidebar-media-wrapper";
|
||||
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
|
||||
cardObj.grid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
if (matchesTagType) {
|
||||
// --- IN-PLACE REUSE PATH ---
|
||||
const mediaEl = currentMediaEl;
|
||||
// Find or create the media tag in-place
|
||||
let mediaEl = wrapper.querySelector("img, video");
|
||||
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
// 1. Update interactive event listeners on reuse to bind correct closure variables
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview([src]);
|
||||
if (needsRebuild) {
|
||||
if (mediaEl) mediaEl.remove();
|
||||
mediaEl = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
Object.assign(mediaEl.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element",
|
||||
zIndex: "1"
|
||||
});
|
||||
wrapper.insertBefore(mediaEl, wrapper.firstChild);
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = mediaEl;
|
||||
|
||||
// Binds interactive event listeners. Passes shiftKey context natively to support split side-loading
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview([src], ev.shiftKey);
|
||||
};
|
||||
|
||||
// Shared dimensions layout compiler
|
||||
const applyDimensions = (width, height) => {
|
||||
if (keepAspect && width && height) {
|
||||
mediaEl.style.aspectRatio = `${width} / ${height}`;
|
||||
}
|
||||
if (cardObj.dimEl && width && height) {
|
||||
cardObj.dimEl.textContent = `${width}x${height}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
if (isVideo) {
|
||||
mediaEl.muted = true;
|
||||
mediaEl.playsInline = true;
|
||||
mediaEl.preload = "metadata";
|
||||
mediaEl.loop = true;
|
||||
mediaEl.setAttribute("draggable", "false");
|
||||
|
||||
mediaEl.onloadedmetadata = () => {
|
||||
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
};
|
||||
|
||||
let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (!playIcon) {
|
||||
playIcon = document.createElement("div");
|
||||
playIcon.className = "comfy-sidebar-play-icon";
|
||||
Object.assign(playIcon.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
|
||||
});
|
||||
playIcon.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
|
||||
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
|
||||
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
|
||||
</svg>
|
||||
`;
|
||||
wrapper.appendChild(playIcon);
|
||||
}
|
||||
|
||||
if (!isVideo) {
|
||||
// Re-bind native drag handlers with updated state values
|
||||
const newDragStart = (e) => {
|
||||
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
|
||||
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
|
||||
} else {
|
||||
const isUnfinished = state.status && state.status !== "completed";
|
||||
if (isUnfinished) {
|
||||
mediaEl.setAttribute("draggable", "false");
|
||||
mediaEl.style.cursor = "grab";
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
delete mediaEl._currentDragStart;
|
||||
} else {
|
||||
mediaEl.setAttribute("draggable", "true");
|
||||
mediaEl.style.cursor = "zoom-in";
|
||||
|
||||
const dragStartHandler = (e) => {
|
||||
State.currentDraggedImgData = { ...img, workflow: state.workflow };
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", src);
|
||||
@@ -284,167 +387,100 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
e.stopPropagation();
|
||||
};
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
mediaEl.addEventListener("dragstart", newDragStart);
|
||||
mediaEl._currentDragStart = newDragStart;
|
||||
mediaEl.addEventListener("dragstart", dragStartHandler);
|
||||
mediaEl._currentDragStart = dragStartHandler;
|
||||
}
|
||||
|
||||
// 2. Perform smooth in-memory source swapping for blob live previews
|
||||
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
|
||||
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
|
||||
if (src.startsWith("blob:") && !isVideo) {
|
||||
// Smooth preload trick: hold current preview until the new one is fully loaded
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = () => {
|
||||
const oldBlob = mediaEl._lastBlob;
|
||||
mediaEl.src = src;
|
||||
mediaEl._lastBlob = src;
|
||||
|
||||
if (keepAspect && tempImg.naturalWidth && tempImg.naturalHeight) {
|
||||
mediaEl.style.aspectRatio = `${tempImg.naturalWidth} / ${tempImg.naturalHeight}`;
|
||||
}
|
||||
if (cardObj.dimEl && tempImg.naturalWidth && tempImg.naturalHeight) {
|
||||
cardObj.dimEl.textContent = `${tempImg.naturalWidth}x${tempImg.naturalHeight}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
|
||||
if (oldBlob && oldBlob !== src) {
|
||||
try { URL.revokeObjectURL(oldBlob); } catch(e){}
|
||||
}
|
||||
};
|
||||
tempImg.src = src;
|
||||
} else {
|
||||
// Direct fast swap for standard server files
|
||||
mediaEl.src = isVideo ? src + "#t=0.001" : src;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update the pagination label if it exists
|
||||
const label = cardObj.grid.querySelector(".comfy-sidebar-batch-label");
|
||||
if (label) {
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
}
|
||||
return;
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
}
|
||||
|
||||
// --- REBUILD PATH (ONLY RUNS ON TYPE CHANGE OR FIRST RENDER) ---
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
|
||||
|
||||
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
|
||||
// Explicit low stacking index combined with standard native webkit dragging force rules
|
||||
Object.assign(thumb.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element",
|
||||
zIndex: "1"
|
||||
});
|
||||
|
||||
if (isVideo) {
|
||||
thumb.muted = true;
|
||||
thumb.playsInline = true;
|
||||
thumb.preload = "metadata";
|
||||
thumb.loop = true;
|
||||
|
||||
const playIcon = document.createElement("div");
|
||||
Object.assign(playIcon.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
|
||||
});
|
||||
|
||||
playIcon.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
|
||||
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
|
||||
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
|
||||
</svg>
|
||||
`;
|
||||
wrapper.appendChild(playIcon);
|
||||
|
||||
thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
|
||||
thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
|
||||
}
|
||||
|
||||
if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
|
||||
|
||||
// Keep native image tags naturally draggable
|
||||
if (!isVideo) {
|
||||
thumb.setAttribute("draggable", "true");
|
||||
} else {
|
||||
thumb.setAttribute("draggable", "false");
|
||||
}
|
||||
|
||||
if (!isVideo) {
|
||||
thumb.onload = () => { if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`; };
|
||||
if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { thumb.src = src; thumb._lastBlob = src; }; tempImg.src = src; } else thumb.src = src;
|
||||
} else {
|
||||
thumb.onloadedmetadata = () => { if (keepAspect && thumb.videoWidth && thumb.videoHeight) thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`; };
|
||||
thumb.src = src + "#t=0.001";
|
||||
}
|
||||
|
||||
thumb.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showFullscreenPreview([src]);
|
||||
};
|
||||
|
||||
wrapper.insertBefore(thumb, wrapper.firstChild);
|
||||
cardObj.firstImgElement = thumb;
|
||||
|
||||
// Overlay slick pagination controller bar if batch has multiple output images
|
||||
if (state.images.length > 1) {
|
||||
const navBar = document.createElement("div");
|
||||
Object.assign(navBar.style, {
|
||||
position: "absolute", bottom: "6px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
|
||||
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
|
||||
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)" // Forces a GPU compositor stacking context to prevent video clipping
|
||||
});
|
||||
|
||||
const prevBtn = document.createElement("span");
|
||||
prevBtn.className = "pi pi-chevron-left";
|
||||
prevBtn.style.cursor = "pointer";
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "comfy-sidebar-batch-label";
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
label.style.cursor = "pointer";
|
||||
label.title = "View all images in this batch";
|
||||
|
||||
// Open the dynamic batch explorer explorer view when the middle counter label is clicked
|
||||
label.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
State.activeSubmenuBatchImages = {
|
||||
pid: state.pid || cardObj.element.id.replace("card-", ""),
|
||||
images: state.images,
|
||||
workflow: state.workflow
|
||||
// Smooth in-memory source swapping for blob live previews
|
||||
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
|
||||
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
|
||||
if (src.startsWith("blob:") && !isVideo) {
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = () => {
|
||||
const oldBlob = mediaEl._lastBlob;
|
||||
mediaEl.src = src;
|
||||
mediaEl._lastBlob = src;
|
||||
applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||
if (oldBlob && oldBlob !== src) {
|
||||
try { URL.revokeObjectURL(oldBlob); } catch(e){}
|
||||
}
|
||||
};
|
||||
renderDOM();
|
||||
};
|
||||
|
||||
const nextBtn = document.createElement("span");
|
||||
nextBtn.className = "pi pi-chevron-right";
|
||||
nextBtn.style.cursor = "pointer";
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
wrapper.appendChild(navBar);
|
||||
tempImg.src = src;
|
||||
} else {
|
||||
mediaEl.src = isVideo ? src + "#t=0.001" : src;
|
||||
}
|
||||
} else {
|
||||
// If the source is already correct, make sure we still force dimensions calculations
|
||||
if (isVideo) {
|
||||
if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
} else {
|
||||
if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
}
|
||||
}
|
||||
|
||||
cardObj.grid.appendChild(wrapper);
|
||||
// Sync batch pagination controller overlay
|
||||
let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar");
|
||||
if (state.images.length > 1) {
|
||||
if (!navBar) {
|
||||
navBar = document.createElement("div");
|
||||
navBar.className = "comfy-sidebar-batch-navbar";
|
||||
Object.assign(navBar.style, {
|
||||
position: "absolute", bottom: "6px", left: "50%",
|
||||
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
|
||||
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
|
||||
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)"
|
||||
});
|
||||
|
||||
const prevBtn = document.createElement("span");
|
||||
prevBtn.className = "pi pi-chevron-left";
|
||||
prevBtn.style.cursor = "pointer";
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "comfy-sidebar-batch-label";
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
label.style.cursor = "pointer";
|
||||
label.title = "View all images in this batch";
|
||||
|
||||
// Open the dynamic batch explorer explorer view when the middle counter label is clicked
|
||||
label.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
State.activeSubmenuBatchImages = {
|
||||
pid: state.pid || cardObj.element.id.replace("card-", ""),
|
||||
images: state.images,
|
||||
workflow: state.workflow
|
||||
};
|
||||
resetAllCardHoverStates(); // Prevent ghost hover handles
|
||||
renderDOM();
|
||||
};
|
||||
|
||||
const nextBtn = document.createElement("span");
|
||||
nextBtn.className = "pi pi-chevron-right";
|
||||
nextBtn.style.cursor = "pointer";
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
wrapper.appendChild(navBar);
|
||||
}
|
||||
|
||||
const label = navBar.querySelector(".comfy-sidebar-batch-label");
|
||||
if (label) label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
} else {
|
||||
if (navBar) navBar.remove();
|
||||
}
|
||||
}
|
||||
|
||||
let globalClickRegistered = false;
|
||||
@@ -463,6 +499,7 @@ const handleGlobalClick = (e) => {
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
State.activeSubmenuBatchImages = null;
|
||||
resetAllCardHoverStates(); // Reset stale overlays when clicking away
|
||||
renderDOM();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
@@ -481,7 +518,7 @@ export function renderDOM() {
|
||||
const headerSearchIcon = State.sidebarContainer.querySelector(".pi-search");
|
||||
const headerActions = State.sidebarContainer.querySelector(".pi-eraser")?.parentNode;
|
||||
|
||||
const styleRightBtn = (btn) => {
|
||||
const styleActionBtn = (btn) => {
|
||||
Object.assign(btn.style, {
|
||||
display: "inline-flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
@@ -508,7 +545,7 @@ export function renderDOM() {
|
||||
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back to Queue";
|
||||
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; renderDOM(); };
|
||||
headerTitle.onclick = () => { State.activeSubmenuBatchImages = null; resetAllCardHoverStates(); renderDOM(); };
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
@@ -561,18 +598,33 @@ export function renderDOM() {
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image";
|
||||
btnImg.title = "Download Object";
|
||||
styleRightBtn(btnImg);
|
||||
styleActionBtn(btnImg);
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
|
||||
// Symmetrical left hover panel containing ONLY btnFocus
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye";
|
||||
btnFocus.title = "Show Node";
|
||||
styleActionBtn(btnFocus);
|
||||
leftHoverPanel.appendChild(btnFocus);
|
||||
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
|
||||
cardElements.set(cardId, cardObj);
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
cardObj.leftHoverPanel.style.display = "flex";
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,6 +639,22 @@ export function renderDOM() {
|
||||
a.click();
|
||||
};
|
||||
|
||||
// Focus/center target node inside Batch Explorer Submenu
|
||||
const nodeId = findNodeIdForImage(batchInfo, img);
|
||||
if (nodeId) {
|
||||
cardObj.btnFocus.style.display = "inline-flex";
|
||||
cardObj.btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const node = app.graph.getNodeById(Number(nodeId));
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cardObj.btnFocus.style.display = "none";
|
||||
}
|
||||
|
||||
renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow }, keepAspect);
|
||||
|
||||
targetElements.push(cardObj.element);
|
||||
@@ -611,7 +679,7 @@ export function renderDOM() {
|
||||
headerTitle.textContent = `Outputs of #${State.activeSubmenuPromptId}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back to Queue";
|
||||
headerTitle.onclick = () => { State.activeSubmenuPromptId = null; renderDOM(); };
|
||||
headerTitle.onclick = () => { State.activeSubmenuPromptId = null; resetAllCardHoverStates(); renderDOM(); };
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
@@ -621,7 +689,7 @@ export function renderDOM() {
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const outputs = getRunOutputs(st.nodeOutputs);
|
||||
const outputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
const targetElements = [];
|
||||
|
||||
outputs.forEach((out) => {
|
||||
@@ -666,18 +734,33 @@ export function renderDOM() {
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image";
|
||||
btnImg.title = "Download Object";
|
||||
styleRightBtn(btnImg);
|
||||
styleActionBtn(btnImg);
|
||||
hoverPanel.appendChild(btnImg);
|
||||
|
||||
// Symmetrical left hover panel containing ONLY btnFocus
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye";
|
||||
btnFocus.title = "Show Node";
|
||||
styleActionBtn(btnFocus);
|
||||
leftHoverPanel.appendChild(btnFocus);
|
||||
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, btnImg, firstImgElement: null };
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
|
||||
cardObj = { element: card, timerEl, dimEl, grid, placeholder: p, hoverPanel, leftHoverPanel, btnFocus, btnImg, firstImgElement: null };
|
||||
cardElements.set(cardId, cardObj);
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
cardObj.leftHoverPanel.style.display = "flex";
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -695,6 +778,16 @@ export function renderDOM() {
|
||||
});
|
||||
};
|
||||
|
||||
// Focus/center target node inside Workflow outputs Submenu
|
||||
cardObj.btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const node = app.graph.getNodeById(Number(out.nodeId));
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow }, keepAspect);
|
||||
} else {
|
||||
cardObj.btnImg.style.display = "none";
|
||||
@@ -765,48 +858,67 @@ export function renderDOM() {
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleRightBtn(btnImg);
|
||||
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleRightBtn(btnJson);
|
||||
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleRightBtn(btnDel);
|
||||
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; btnImg.title = "Download Object"; styleActionBtn(btnImg);
|
||||
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; btnJson.title = "Download JSON"; styleActionBtn(btnJson);
|
||||
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash"; btnDel.title = "Delete Card"; styleActionBtn(btnDel);
|
||||
|
||||
// Bottom left Explorer Submenu Button
|
||||
const leftHoverBtn = document.createElement("span");
|
||||
leftHoverBtn.className = "pi pi-images comfy-sidebar-left-hover-btn";
|
||||
leftHoverBtn.title = "View all intermediate outputs";
|
||||
Object.assign(leftHoverBtn.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
width: "32px", height: "32px",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "14px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: "20",
|
||||
transform: "translateZ(0)"
|
||||
// Absolute Vertical Left Actions Panel containing btnFocus and leftHoverBtn
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", display: "none",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
leftHoverBtn.onmouseenter = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.95)"; leftHoverBtn.style.color = "#fff"; };
|
||||
leftHoverBtn.onmouseleave = () => { leftHoverBtn.style.backgroundColor = "rgba(0, 0, 0, 0.75)"; leftHoverBtn.style.color = "#e2e8f0"; };
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye";
|
||||
btnFocus.title = "Show Node";
|
||||
styleActionBtn(btnFocus);
|
||||
|
||||
const leftHoverBtn = document.createElement("span");
|
||||
leftHoverBtn.className = "pi pi-images";
|
||||
leftHoverBtn.title = "View all intermediate outputs";
|
||||
styleActionBtn(leftHoverBtn);
|
||||
|
||||
leftHoverBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
State.activeSubmenuPromptId = state.pid;
|
||||
resetAllCardHoverStates(); // Reset standard sidebar hover states before opening submenu
|
||||
renderDOM();
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnJson, btnDel);
|
||||
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverBtn);
|
||||
leftHoverPanel.append(btnFocus, leftHoverBtn);
|
||||
|
||||
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
if (state.status !== "active" && state.status !== "pending") {
|
||||
cardObj.hoverPanel.style.display = "flex";
|
||||
// Show explorer view button if this run yielded multiple node outputs
|
||||
const outputs = getRunOutputs(state.nodeOutputs);
|
||||
if (outputs.length > 1) {
|
||||
cardObj.leftHoverPanel.style.display = "flex";
|
||||
|
||||
// Focus/Center the exact node that generated the currently active displayed image
|
||||
const currentImg = state.images[cardObj.currentImageIndex || 0];
|
||||
const nodeId = findNodeIdForImage(state, currentImg);
|
||||
if (nodeId) {
|
||||
cardObj.btnFocus.style.display = "inline-flex";
|
||||
cardObj.btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const node = app.graph.getNodeById(Number(nodeId));
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cardObj.btnFocus.style.display = "none";
|
||||
}
|
||||
|
||||
// Show explorer view button if this run yielded multiple node outputs, or if it was interrupted/failed but has some outputs
|
||||
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
|
||||
const isInterrupted = state.status === "cancelled" || state.status === "error";
|
||||
if (outputs.length > 1 || (outputs.length > 0 && isInterrupted)) {
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
} else {
|
||||
cardObj.leftHoverBtn.style.display = "none";
|
||||
@@ -815,13 +927,14 @@ export function renderDOM() {
|
||||
});
|
||||
card.addEventListener("mouseleave", () => {
|
||||
cardObj.hoverPanel.style.display = "none";
|
||||
cardObj.leftHoverBtn.style.display = "none";
|
||||
cardObj.leftHoverPanel.style.display = "none";
|
||||
});
|
||||
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
// Custom dragstart is only used for JSON-only workflows to supply a local DownloadURL.
|
||||
// For image cards, dragging is handled natively by the browser on the child <img> tag.
|
||||
if (state.workflow && (!state.images || state.images.length === 0)) {
|
||||
// Custom dragstart is used for JSON-only or unfinished workflows to supply a local DownloadURL.
|
||||
// For completed image cards, dragging is handled natively by the browser on the child <img> tag.
|
||||
const isUnfinished = state.status && state.status !== "completed";
|
||||
if (state.workflow && (!state.images || state.images.length === 0 || isUnfinished)) {
|
||||
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
|
||||
const jsonStr = JSON.stringify(state.workflow, null, 2);
|
||||
try {
|
||||
@@ -832,14 +945,16 @@ export function renderDOM() {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
});
|
||||
cardObj.element.id = `card-${state.pid}`; // set ID helper
|
||||
cardElements.set(state.pid, cardObj);
|
||||
}
|
||||
|
||||
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
|
||||
|
||||
// Native drag isolation: completely remove draggable flags on image containers.
|
||||
// Native drag isolation: completely remove draggable flags on image containers for finished cards.
|
||||
// This exposes the child <img> tag directly to Chrome's native filesystem drag APIs.
|
||||
if (state.images && state.images.length > 0) {
|
||||
const isUnfinished = state.status && state.status !== "completed";
|
||||
if (state.images && state.images.length > 0 && !isUnfinished) {
|
||||
cardObj.element.removeAttribute("draggable");
|
||||
} else {
|
||||
cardObj.element.setAttribute("draggable", "true");
|
||||
|
||||
+21
-4
@@ -1,13 +1,29 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
const s = 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);
|
||||
}
|
||||
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) {
|
||||
export function findImagesInOutputs(outputs, workflow) {
|
||||
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)) {
|
||||
@@ -22,10 +38,11 @@ export function findImagesInOutputs(outputs) {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function findTextsInOutputs(outputs) {
|
||||
export function findTextsInOutputs(outputs, workflow) {
|
||||
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)) {
|
||||
@@ -49,11 +66,11 @@ export function findTextsInOutputs(outputs) {
|
||||
}
|
||||
|
||||
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
|
||||
export function getRunOutputs(nodeOutputs) {
|
||||
export function getRunOutputs(nodeOutputs, workflow) {
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] });
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
|
||||
if (imgs.length > 0) {
|
||||
list.push({ nodeId, images: imgs });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user