Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a9092001 | ||
|
|
9797d1ac7a | ||
|
|
d0424d0d05 | ||
|
|
c72407690c | ||
|
|
31c994a1b6 | ||
|
|
9219f2d042 | ||
|
|
b3e6b1bd62 | ||
|
|
2dc92d3e6f | ||
|
|
e83c999da7 | ||
|
|
f61d6c8902 | ||
|
|
689e020230 | ||
|
|
ad4e138eac | ||
|
|
8487150e6f | ||
|
|
0e120f1af1 | ||
|
|
b6bdaefacc | ||
|
|
5adf5ea513 | ||
|
|
f6e76b1789 | ||
|
|
0e648a9fa9 | ||
|
|
684726789f | ||
|
|
c2c82065a7 | ||
|
|
5fde92b51a | ||
|
|
c43863a219 | ||
|
|
111de1a8c2 | ||
|
|
92653bfa75 | ||
|
|
bf2cdc4bcd | ||
|
|
d85c54232a | ||
|
|
c49859a75b | ||
|
|
08454ff7db | ||
|
|
2fa512912e | ||
|
|
65900536ce | ||
|
|
7dbde17bcd | ||
|
|
a63c7d2afd | ||
|
|
3ccd3b6a7c | ||
|
|
735cac9c97 | ||
|
|
a9575f1a1c |
@@ -0,0 +1,42 @@
|
||||
name: Publish to Comfy registry & GitHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Gives GitHub permission to create tags & releases
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
# 1. Read version string from pyproject.toml
|
||||
- name: Extract Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(grep -m 1 'version' pyproject.toml | cut -d '"' -f 2)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
# 2. Automatically create Git Tag & GitHub Release
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ env.VERSION }}
|
||||
name: v${{ env.VERSION }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,18 +1,24 @@
|
||||
# ComfyUI-ComfySidebar
|
||||
|
||||
A lightweight, high-performance ComfyUI extension that replaces the cluttered stock Job History/Queue interface with a compact drag&drop queue panel, inspired by the pre-vue ComfyUI frontend design.
|
||||
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
|
||||
|
||||
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* **Advanced Drag & Drop Support**: Designed specifically for precise mouse pointer controls. Easily drag previous generations back to the empty canvas to restore workflows, or drop images directly into `LoadImage` and input nodes to hot-swap them.
|
||||
* **High-Visibility Preview Cards**: Keep your past runs instantly recognizable. Supports aspect-ratio locked thumbnails for images and auto-playing muted loops for video formats, as well as clean rendering for multi-line text outputs.
|
||||
* **Quick Save Buttons**: Dedicated interactive shortcuts appear on hover, allowing you to instantly download generation outputs or export exact workflow JSON files with a single click.
|
||||
* **Compact Adaptive Design**: A clean, smart layout that automatically balances single or multi-column grids based on your sidebar width. It maximizes viewable content and respects your active screen area without wasting precious space.
|
||||
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
|
||||
* 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="287" height="685" alt="image" src="https://github.com/user-attachments/assets/ab414a37-d585-4bcb-aade-cf20ab5b24d8" />
|
||||
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
|
||||
|
||||
|
||||
## 🛠️ Installation
|
||||
@@ -29,8 +35,31 @@ 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"**.
|
||||
Open **Settings → Comfy Sidebar** to configure:
|
||||
|
||||
* Queue grid layout
|
||||
* Result aspect ratio
|
||||
* Queue display mode
|
||||
* Working node display
|
||||
* Automatic cleanup of cancelled/failed jobs
|
||||
* Sidebar tabs to hide
|
||||
* Stock Job History replacement
|
||||
* Graph button visibility
|
||||
* Unified top bar layout
|
||||
|
||||
## ⌨️ Shortcuts
|
||||
|
||||
* **`Q`**: Instantly toggles the sidebar panel open or closed. Safely ignores inputs if you are currently typing inside a text field, input area, or combo box.
|
||||
| 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"]
|
||||
@@ -0,0 +1,407 @@
|
||||
import { isVideoFormat, is3DFormat } from "./utils.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
let activeComparisonViewer = null;
|
||||
let globalKeydownHandler = null;
|
||||
|
||||
// Safe clientX resolver for mouse and touch events
|
||||
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 createMediaElement = (src, muted = false) => {
|
||||
const isVideo = isVideoFormat(src);
|
||||
const el = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
if (isVideo) {
|
||||
el.muted = muted;
|
||||
el.playsInline = true;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.controls = false;
|
||||
}
|
||||
Object.assign(el.style, {
|
||||
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
|
||||
objectFit: "contain", pointerEvents: "none"
|
||||
});
|
||||
el.src = isVideo ? src + "#t=0.001" : src;
|
||||
return el;
|
||||
};
|
||||
|
||||
const setupVideoPlayback = (vid, container) => {
|
||||
if (!vid) return () => {};
|
||||
|
||||
let videoSyncActive = true;
|
||||
let syncAnimationFrameId = null;
|
||||
|
||||
const onMetaLoaded = () => {
|
||||
vid.play().catch(()=>{});
|
||||
};
|
||||
vid.addEventListener("loadedmetadata", onMetaLoaded);
|
||||
if (vid.readyState >= 1) onMetaLoaded();
|
||||
|
||||
const controlBar = document.createElement("div");
|
||||
Object.assign(controlBar.style, {
|
||||
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",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)", transform: "translate3d(-50%, 0, 0)",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("span");
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
playBtn.style.cursor = "pointer";
|
||||
playBtn.style.fontSize = "14px";
|
||||
playBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (vid.paused) {
|
||||
vid.play().catch(()=>{});
|
||||
playBtn.className = "pi pi-pause";
|
||||
} else {
|
||||
vid.pause();
|
||||
playBtn.className = "pi pi-play";
|
||||
}
|
||||
};
|
||||
|
||||
const scrubberContainer = document.createElement("div");
|
||||
Object.assign(scrubberContainer.style, {
|
||||
flex: "1", height: "4px", background: "#444", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, {
|
||||
width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px"
|
||||
});
|
||||
scrubberContainer.appendChild(scrubberFill);
|
||||
|
||||
const timeLabel = document.createElement("span");
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
const scrub = (e) => {
|
||||
const rect = scrubberContainer.getBoundingClientRect();
|
||||
const clientX = getClientX(e);
|
||||
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
|
||||
const duration = vid.duration || 0;
|
||||
if (duration > 0) {
|
||||
vid.currentTime = (percent / 100) * duration;
|
||||
scrubberFill.style.width = `${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
let isScrubbing = false;
|
||||
scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
|
||||
scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
|
||||
|
||||
const handleWindowMove = (e) => { if (isScrubbing) scrub(e); };
|
||||
const handleWindowUp = () => { isScrubbing = false; };
|
||||
window.addEventListener("mousemove", handleWindowMove);
|
||||
window.addEventListener("touchmove", handleWindowMove);
|
||||
window.addEventListener("mouseup", handleWindowUp);
|
||||
window.addEventListener("touchend", handleWindowUp);
|
||||
|
||||
controlBar.append(playBtn, scrubberContainer, timeLabel);
|
||||
container.appendChild(controlBar);
|
||||
|
||||
const syncLoop = () => {
|
||||
if (!videoSyncActive) return;
|
||||
|
||||
const cur = vid.currentTime || 0;
|
||||
const dur = vid.duration || 0;
|
||||
if (dur > 0) {
|
||||
scrubberFill.style.width = `${(cur / dur) * 100}%`;
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
|
||||
}
|
||||
|
||||
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
|
||||
syncAnimationFrameId = requestAnimationFrame(syncLoop);
|
||||
};
|
||||
requestAnimationFrame(syncLoop);
|
||||
|
||||
return () => {
|
||||
videoSyncActive = false;
|
||||
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
|
||||
window.removeEventListener("mousemove", handleWindowMove);
|
||||
window.removeEventListener("touchmove", handleWindowMove);
|
||||
window.removeEventListener("mouseup", handleWindowUp);
|
||||
window.removeEventListener("touchend", handleWindowUp);
|
||||
vid.removeEventListener("loadedmetadata", onMetaLoaded);
|
||||
controlBar.remove();
|
||||
};
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
const isBaseVideo = isVideoFormat(baseSrc);
|
||||
const canvasEl = document.querySelector("#graph-canvas, canvas");
|
||||
const targetContainer = canvasEl ? canvasEl.parentNode : document.body;
|
||||
|
||||
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 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"
|
||||
});
|
||||
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.";
|
||||
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);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, {
|
||||
position: "relative", display: "grid", placeItems: "center",
|
||||
maxWidth: "85%", maxHeight: "85%"
|
||||
});
|
||||
container.appendChild(wrapper);
|
||||
|
||||
const hintPrompt = document.createElement("div");
|
||||
Object.assign(hintPrompt.style, {
|
||||
position: "absolute", bottom: "16px", zIndex: "30",
|
||||
color: "#888", fontSize: "12px", fontFamily: "sans-serif",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#aaa;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
hintPrompt.style.display = "none";
|
||||
}
|
||||
container.appendChild(hintPrompt);
|
||||
|
||||
let wasDragging = false;
|
||||
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) {
|
||||
if (wasDragging) {
|
||||
wasDragging = false;
|
||||
return;
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
let mediaA = createMediaElement(baseSrc, false);
|
||||
wrapper.appendChild(mediaA);
|
||||
|
||||
let mediaB = null;
|
||||
|
||||
const slider = document.createElement("div");
|
||||
Object.assign(slider.style, {
|
||||
position: "absolute", top: "0", bottom: "0", left: "50%",
|
||||
width: "2px", background: "#fff", cursor: "ew-resize",
|
||||
zIndex: "25", display: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
|
||||
});
|
||||
const sliderButton = document.createElement("div");
|
||||
Object.assign(sliderButton.style, {
|
||||
position: "absolute", top: "50%", left: "50%",
|
||||
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"
|
||||
});
|
||||
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}%`;
|
||||
if (mediaB) {
|
||||
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
el.src = "";
|
||||
el.load();
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
}
|
||||
|
||||
return {
|
||||
loadTarget(targetSrc, isShiftClick) {
|
||||
const isTargetVideo = isVideoFormat(targetSrc);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isShiftClick) {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
}
|
||||
|
||||
mediaB = createMediaElement(targetSrc, false);
|
||||
mediaB.style.pointerEvents = "none";
|
||||
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);
|
||||
} else {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
mediaB.remove();
|
||||
mediaB = null;
|
||||
}
|
||||
slider.style.display = "none";
|
||||
mediaA.src = targetSrc;
|
||||
infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare.";
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
const src = imgSrcs[0];
|
||||
|
||||
// Handle 3D assets natively or by focusing 3D node
|
||||
if (is3DFormat(src)) {
|
||||
if (window.app?.ui?.show3DViewer) {
|
||||
window.app.ui.show3DViewer(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.graph && app.canvas) {
|
||||
const nodes = app.graph._nodes || [];
|
||||
const node = nodes.find(n =>
|
||||
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
|
||||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
|
||||
);
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(src);
|
||||
}
|
||||
Vendored
+100
-57
@@ -5,89 +5,132 @@ async function uploadDroppedImageToInput(imageObj) {
|
||||
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
if (!response.ok) return null;
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], imageObj.filename || "dropped_image.png", { type: blob.type });
|
||||
const file = new File([blob], imageObj.filename || "dropped_file", { type: blob.type });
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("overwrite", "true");
|
||||
formData.append("type", "input");
|
||||
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
|
||||
if (!uploadRes.ok) return null;
|
||||
const uploadData = await uploadRes.json();
|
||||
return uploadData.name;
|
||||
} catch(e) {
|
||||
console.error("Comfy Sidebar: Failed to copy image to input.", e);
|
||||
console.error("Comfy Sidebar: Failed to copy file to input.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDragAndDrop() {
|
||||
document.addEventListener("dragover", (e) => {
|
||||
if (State.currentDraggedImgData) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
|
||||
document.addEventListener("drop", async (e) => {
|
||||
if (!State.currentDraggedImgData) return;
|
||||
|
||||
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) {
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
if (!canvas || !canvas.graph) return;
|
||||
|
||||
let droppedOnImageNode = false;
|
||||
|
||||
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
|
||||
if (State.currentDraggedImgData.filename) {
|
||||
// 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();
|
||||
droppedOnImageNode = true;
|
||||
const widget = targetNode.widgets.find(w => w.name === "image");
|
||||
if (widget) {
|
||||
const newFilename = await uploadDroppedImageToInput(State.currentDraggedImgData);
|
||||
if (newFilename) {
|
||||
widget.value = newFilename;
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
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 / 3D URL drop
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
if (urlObj.pathname === "/view") {
|
||||
const filename = urlObj.searchParams.get("filename");
|
||||
const type = urlObj.searchParams.get("type") || "output";
|
||||
const subfolder = urlObj.searchParams.get("subfolder") || "";
|
||||
|
||||
if (filename) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let targetNode = null;
|
||||
if (canvas.convertEventToCanvasOffset) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e);
|
||||
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
|
||||
} else if (canvas.canvas) {
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
// Check if dropped on a LoadImage, LoadVideo, Load3D, VHS_LoadVideo, or image/video/3D widget node
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" || w.type === "image" || w.type === "customtext"
|
||||
);
|
||||
|
||||
const isLoadNode = targetNode && (
|
||||
widget ||
|
||||
targetNode.type?.includes("LoadImage") ||
|
||||
targetNode.type?.includes("LoadVideo") ||
|
||||
targetNode.type?.includes("VHS_LoadVideo") ||
|
||||
targetNode.type?.includes("Load3D") ||
|
||||
targetNode.type?.includes("Load 3D") ||
|
||||
targetNode.type?.includes("Preview3D")
|
||||
);
|
||||
|
||||
if (isLoadNode) {
|
||||
const targetWidget = widget || targetNode.widgets?.[0];
|
||||
if (targetWidget) {
|
||||
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
|
||||
if (newFilename) {
|
||||
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
|
||||
if (!targetWidget.options.values.includes(newFilename)) {
|
||||
targetWidget.options.values.push(newFilename);
|
||||
}
|
||||
}
|
||||
targetWidget.value = newFilename;
|
||||
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
|
||||
targetNode.imgs = null;
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas area: fetch file and load embedded workflow metadata
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!droppedOnImageNode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (State.currentDraggedImgData.workflow) {
|
||||
if (app.loadGraphData) {
|
||||
app.loadGraphData(State.currentDraggedImgData.workflow);
|
||||
} else if (app.handleFile) {
|
||||
const jsonStr = JSON.stringify(State.currentDraggedImgData.workflow);
|
||||
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
|
||||
await app.handleFile(file);
|
||||
}
|
||||
} else if (State.currentDraggedImgData.filename) {
|
||||
const src = State.currentDraggedImgData.url || `/view?filename=${encodeURIComponent(State.currentDraggedImgData.filename)}&type=${State.currentDraggedImgData.type || 'output'}&subfolder=${encodeURIComponent(State.currentDraggedImgData.subfolder || '')}`;
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], State.currentDraggedImgData.filename || "workflow.png", { type: blob.type });
|
||||
|
||||
if (app.handleFile) await app.handleFile(file);
|
||||
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to synthesize workflow file drop:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
|
||||
}
|
||||
}
|
||||
State.currentDraggedImgData = null;
|
||||
}, true);
|
||||
}
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
const CLASSIC_LAYOUT_CSS_MEDIA = `
|
||||
/* Remove the ghost underlay container border & background (the oval) without affecting the topbar header */
|
||||
.actionbar-container,
|
||||
.shadow-interface.rounded-lg.bg-comfy-menu-bg,
|
||||
.shadow-interface.rounded-lg.border-interface-stroke,
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
|
||||
[class*="actionbar"]:not(.actionbar),
|
||||
[class*="actionbar-buttons"],
|
||||
.actionbar-buttons {
|
||||
position: fixed !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;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
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;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
height: auto !important;
|
||||
position: static !important;
|
||||
order: -10 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
/* Scale down performance indicators (Crystools) uniformly */
|
||||
#crysmonitor-monitors-root,
|
||||
#crystools-monitors-root,
|
||||
.crysmonitor-monitors-container {
|
||||
zoom: 0.8 !important;
|
||||
display: inline-flex !important;
|
||||
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"]),
|
||||
[role="button"]:has(img[alt*="User Avatar"]),
|
||||
button:has(img[alt*="user avatar"]),
|
||||
[role="button"]:has(img[alt*="user avatar"]),
|
||||
.p-avatar,
|
||||
[data-pc-name="avatar"],
|
||||
button:has(.p-avatar),
|
||||
[role="button"]:has(.p-avatar),
|
||||
button:has([data-pc-name="avatar"]),
|
||||
[role="button"]:has([data-pc-name="avatar"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 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"] {
|
||||
padding-left: 100px !important;
|
||||
padding-right: 420px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard laptops / small monitors (between 900px and 1199px width) */
|
||||
@media (max-width: 1199px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 40px !important;
|
||||
padding-right: 280px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very narrow or heavily zoomed displays (< 900px width) */
|
||||
@media (max-width: 899px) {
|
||||
.p-tabview-nav-content,
|
||||
[class*="tabview"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 180px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Native settings synchronization without direct DOM element querying or simulated clicks.
|
||||
*/
|
||||
export function syncStockHistoryAndProgressSettings(enable) {
|
||||
const dockedVal = !!enable;
|
||||
const progressVal = !enable;
|
||||
const QPOV2_ID = "Comfy.Queue.QPOV2";
|
||||
const progressKey = "Comfy.Queue.ShowRunProgressBar";
|
||||
|
||||
// Use official extensionManager setting API if present, with fallback to app.ui.settings
|
||||
if (app.extensionManager?.setting) {
|
||||
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
|
||||
} else if (app.ui?.settings) {
|
||||
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
|
||||
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
|
||||
}
|
||||
|
||||
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
|
||||
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
|
||||
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
|
||||
}
|
||||
|
||||
export function applyClassicLayout(enable, updateSetting = false) {
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
|
||||
if (enable) {
|
||||
if (updateSetting) {
|
||||
if (app.extensionManager?.setting) {
|
||||
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
} else if (app.ui?.settings) {
|
||||
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
|
||||
}
|
||||
}
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
if (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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let savedButtonData = null;
|
||||
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');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const origBtn = findOriginalButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
|
||||
const tabIconSelectors = {
|
||||
"Assets": '[class*="comfy--image-ai-edit"]',
|
||||
"Nodes": '[class*="comfy--node"]',
|
||||
"Models": '[class*="comfy--ai-model"]',
|
||||
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
|
||||
"Apps": '[class*="lucide--panels-top-left"]',
|
||||
"Templates": '[class*="comfy--template"]'
|
||||
};
|
||||
|
||||
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
|
||||
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Junk.${tab}`) ?? false;
|
||||
const icon = sidebar.querySelector(selector);
|
||||
if (icon) {
|
||||
const tabBtn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || icon;
|
||||
if (shouldHide) {
|
||||
if (tabBtn.style.display !== "none") tabBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (tabBtn.style.display === "none") tabBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const historyIcon = sidebar.querySelector('[class*="lucide--history"]');
|
||||
if (historyIcon) {
|
||||
const historyBtn = historyIcon.closest('.comfyui-sidebar-tab, button, [role="tab"], li, a, .comfyui-sidebar-item') || historyIcon;
|
||||
if (hideStockHistory) {
|
||||
if (historyBtn.style.display !== "none") historyBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (historyBtn.style.display === "none") historyBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
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, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
return el.closest('.group, .p-1') || el;
|
||||
}
|
||||
|
||||
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, .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.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
const stockDot = document.querySelector('.comfy-sidebar-hide-original-properties-btn ~ span, .comfy-sidebar-hide-original-properties-btn + span');
|
||||
let customDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
|
||||
if (stockDot) {
|
||||
if (!customDot) {
|
||||
customDot = document.createElement("span");
|
||||
customDot.className = "comfy-sidebar-error-dot inline-flex items-center justify-center rounded-full bg-destructive-background text-white size-2 absolute -top-1 -right-1";
|
||||
customBtn.appendChild(customDot);
|
||||
}
|
||||
} else if (customDot) {
|
||||
customDot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHTML(html) {
|
||||
if (!html) return "";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
const tempDot = tempDiv.querySelector('[class*="destructive"]');
|
||||
if (tempDot) tempDot.remove();
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
export function syncClassicLayout() {
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
try {
|
||||
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
|
||||
const hideGraphBtn = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Graph Button") ?? false;
|
||||
const graphBtn = findGraphButton();
|
||||
if (graphBtn) {
|
||||
if (hideGraphBtn) {
|
||||
if (graphBtn.style.display !== "none") graphBtn.style.setProperty("display", "none", "important");
|
||||
} else {
|
||||
if (graphBtn.style.display === "none") graphBtn.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
|
||||
const extensionsPanel = findNativeExtensionsPanel();
|
||||
if (extensionsPanel) {
|
||||
if (isClassicLayoutEnabled) {
|
||||
const container = findTopbarContainer();
|
||||
if (container && extensionsPanel.parentNode !== container) {
|
||||
if (!extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent = extensionsPanel.parentNode;
|
||||
extensionsPanel._originalNextSibling = extensionsPanel.nextSibling;
|
||||
}
|
||||
container.appendChild(extensionsPanel);
|
||||
extensionsPanel.classList.add("comfy-sidebar-extensions-override");
|
||||
}
|
||||
} else if (extensionsPanel._originalParent && extensionsPanel.parentNode !== extensionsPanel._originalParent) {
|
||||
extensionsPanel._originalParent.insertBefore(extensionsPanel, extensionsPanel._originalNextSibling || null);
|
||||
extensionsPanel.classList.remove("comfy-sidebar-extensions-override");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
const customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
if (customBtn) customBtn.remove();
|
||||
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator && indicator.style.display === "none") indicator.style.removeProperty("display");
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
if (originalBtn) {
|
||||
if (!originalBtn.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.add("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
|
||||
savedButtonData = {
|
||||
className: originalBtn.className.replace("comfy-sidebar-hide-original-properties-btn", "").trim(),
|
||||
innerHTML: originalBtn.innerHTML,
|
||||
tagName: originalBtn.tagName,
|
||||
attributes: Array.from(originalBtn.attributes).map(attr => ({
|
||||
name: attr.name,
|
||||
value: attr.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
let customBtn = document.querySelector(".comfy-sidebar-custom-properties-toggle");
|
||||
|
||||
if (!customBtn && savedButtonData && container) {
|
||||
customBtn = document.createElement(savedButtonData.tagName);
|
||||
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
|
||||
customBtn.innerHTML = cleanHTML(savedButtonData.innerHTML);
|
||||
|
||||
for (const attr of savedButtonData.attributes) {
|
||||
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
|
||||
customBtn.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
|
||||
customBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(customBtn);
|
||||
}
|
||||
|
||||
if (customBtn) {
|
||||
if (originalBtn) {
|
||||
const cleanedHTML = cleanHTML(originalBtn.innerHTML);
|
||||
if (cleanedHTML && customBtn.innerHTML !== cleanedHTML) {
|
||||
const existingDot = customBtn.querySelector('.comfy-sidebar-error-dot');
|
||||
customBtn.innerHTML = cleanedHTML;
|
||||
if (existingDot) customBtn.appendChild(existingDot);
|
||||
}
|
||||
|
||||
const titleVal = originalBtn.getAttribute("title") || originalBtn.getAttribute("aria-label") || "Toggle properties panel";
|
||||
customBtn.setAttribute("title", titleVal);
|
||||
customBtn.setAttribute("aria-label", titleVal);
|
||||
|
||||
for (const cls of originalBtn.classList) {
|
||||
if (cls !== "comfy-sidebar-hide-original-properties-btn" && !customBtn.classList.contains(cls)) {
|
||||
customBtn.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
syncErrorBadge(originalBtn, customBtn);
|
||||
} else if (!customBtn.getAttribute("title")) {
|
||||
customBtn.setAttribute("title", "Toggle properties panel");
|
||||
customBtn.setAttribute("aria-label", "Toggle properties panel");
|
||||
}
|
||||
customBtn.classList.toggle("comfy-panel-open", openState);
|
||||
customBtn.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const indicator = findActiveQueueIndicator();
|
||||
if (indicator) {
|
||||
if (hideQueueIndicator) {
|
||||
if (indicator.style.display !== "none") indicator.style.setProperty("display", "none", "important");
|
||||
} else if (indicator.style.display === "none") {
|
||||
indicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarTabsVisibility();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Error inside layout sync routine:", err);
|
||||
} finally {
|
||||
if (domObserver) {
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPropertiesPanelToggleFix() {
|
||||
if (!document.getElementById("comfy-sidebar-layout-fix-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-layout-fix-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-hide-original-properties-btn {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-hide-original-properties-btn ~ span,
|
||||
.comfy-sidebar-hide-original-properties-btn + span {
|
||||
display: none !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle {
|
||||
order: 99999 !important;
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
margin-left: -8px !important;
|
||||
margin-right: 0px !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
.comfy-sidebar-custom-properties-toggle > span.comfy-sidebar-error-dot {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldSync = false;
|
||||
|
||||
const isInternalSidebarMutation = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
return node.closest?.('.comfyui-sidebar, .comfy-sidebar, .comfy-sidebar-comparison-overlay') ||
|
||||
node.classList?.contains('comfy-sidebar-card');
|
||||
};
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
|
||||
const hasExternalElementMutation =
|
||||
Array.from(mutation.addedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node)) ||
|
||||
Array.from(mutation.removedNodes).some(node => node.nodeType === 1 && !isInternalSidebarMutation(node));
|
||||
|
||||
if (hasExternalElementMutation) {
|
||||
shouldSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldSync && !syncScheduled) {
|
||||
syncScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
syncClassicLayout();
|
||||
syncScheduled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
syncClassicLayout();
|
||||
}
|
||||
+137
-57
@@ -1,7 +1,7 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, loadStatesFromLocalStorage } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
|
||||
import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } 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);
|
||||
@@ -65,34 +66,62 @@ export async function syncQueue() {
|
||||
}
|
||||
|
||||
const concludeRun = async (pid, statusStr) => {
|
||||
if (!pid || !promptStates.has(pid)) return;
|
||||
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
|
||||
const key = String(pid);
|
||||
if (!key || !promptStates.has(key)) return;
|
||||
if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null;
|
||||
|
||||
const st = promptStates.get(pid);
|
||||
const st = promptStates.get(key);
|
||||
|
||||
if (st._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
delete st._previewBlobUrl;
|
||||
}
|
||||
if (st._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._oldPreviewBlobUrl); } catch(e){}
|
||||
delete st._oldPreviewBlobUrl;
|
||||
}
|
||||
|
||||
st.status = statusStr;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
st.endTime = Date.now();
|
||||
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/history/${pid}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[pid]) {
|
||||
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
|
||||
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs);
|
||||
st.texts = findTextsInOutputs(hItem[pid].outputs);
|
||||
}
|
||||
} 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();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
api.addEventListener("status", syncQueue);
|
||||
|
||||
api.addEventListener("reconnected", async () => {
|
||||
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
|
||||
await initSessionAndHistory();
|
||||
await syncQueue();
|
||||
});
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
// Auto-sweep cancelled and errored jobs if setting is enabled
|
||||
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
|
||||
const toDelete = [];
|
||||
for (const [p, s] of promptStates.entries()) {
|
||||
@@ -103,10 +132,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();
|
||||
}
|
||||
}
|
||||
|
||||
const pid = e.detail.prompt_id;
|
||||
const pid = String(e.detail.prompt_id);
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
|
||||
@@ -122,14 +152,26 @@ export function setupApiListeners() {
|
||||
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
|
||||
});
|
||||
}
|
||||
renderDOMFn();
|
||||
syncQueue();
|
||||
});
|
||||
|
||||
api.addEventListener("progress", (e) => {
|
||||
const pid = e.detail.prompt_id;
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
promptStates.get(pid).progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
renderDOMFn();
|
||||
const st = promptStates.get(pid);
|
||||
st.progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
|
||||
const cardObj = cardElements.get(pid);
|
||||
if (cardObj && cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${st.progress}%`;
|
||||
if (cardObj.statusText && cardObj.statusText.style.display !== "none") {
|
||||
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Sampling...";
|
||||
cardObj.statusText.textContent = `${nodeName} ${st.progress}%`;
|
||||
}
|
||||
} else {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -153,18 +195,31 @@ export function setupApiListeners() {
|
||||
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
|
||||
if (activeTasks.length > 0) {
|
||||
const st = activeTasks[0];
|
||||
if (st._previewBlobUrl) try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
st._previewBlobUrl = URL.createObjectURL(e.detail);
|
||||
st.images = [{ url: st._previewBlobUrl }];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
st._oldPreviewBlobUrl = st._previewBlobUrl;
|
||||
st._previewBlobUrl = newBlobUrl;
|
||||
st.images = [{ url: newBlobUrl }];
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("executed", (e) => {
|
||||
if (promptStates.has(e.detail.prompt_id)) {
|
||||
const finalImgs = findImagesInOutputs({ output: e.detail.output });
|
||||
if (finalImgs.length > 0) promptStates.get(e.detail.prompt_id).images = finalImgs;
|
||||
promptStates.get(e.detail.prompt_id).texts = findTextsInOutputs({ output: e.detail.output });
|
||||
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 nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
st.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
st.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
});
|
||||
@@ -178,38 +233,63 @@ export function setupApiListeners() {
|
||||
}
|
||||
|
||||
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) {
|
||||
loadStatesFromLocalStorage();
|
||||
} else {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
if (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 images = findImagesInOutputs(historyData[id].outputs);
|
||||
const texts = findTextsInOutputs(historyData[id].outputs);
|
||||
if (images.length === 0 && texts.length === 0) return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(id, {
|
||||
pid: id, status: "completed", images, texts,
|
||||
workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null,
|
||||
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");
|
||||
|
||||
// Filter out empty records from history so dummy "No Outputs" cards don't pollute queue
|
||||
if (images.length === 0 && texts.length === 0 && status === "completed") return;
|
||||
|
||||
State.globalOrderCounter++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
status: status,
|
||||
images: images,
|
||||
texts: texts,
|
||||
nodeOutputs: outputs,
|
||||
workflow: workflow,
|
||||
progressText: "",
|
||||
timestamp: State.globalOrderCounter,
|
||||
rendered: true
|
||||
});
|
||||
});
|
||||
|
||||
pruneHistory(app);
|
||||
saveStatesToLocalStorage();
|
||||
await syncQueue();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
|
||||
}
|
||||
}
|
||||
+304
-17
@@ -3,6 +3,172 @@ import { injectStyles } from "./styles.js";
|
||||
import { setupDragAndDrop } from "./dragdrop.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM } from "./ui.js";
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings } from "./layout.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
|
||||
// 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();
|
||||
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
// Robust multi-selector search for Vue node containers across different frontend versions
|
||||
const nodeEl = document.querySelector(
|
||||
`.comfy-node[data-node-id="${node.id}"], ` +
|
||||
`[data-node-id="${node.id}"], ` +
|
||||
`[data-id="${node.id}"]`
|
||||
);
|
||||
if (!nodeEl) return;
|
||||
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
if (!titleTextNode) {
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) badge.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncAllNodeBadges() {
|
||||
applySidebarOverride();
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
}
|
||||
|
||||
function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
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;
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
@@ -12,46 +178,151 @@ app.registerExtension({
|
||||
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.Override Stock Job History Tab", name: "Replaces the stock Job History sidebar with Comfy Queue", type: "boolean", defaultValue: false });
|
||||
|
||||
// Settings toggle for Auto-clear Interrupted runs
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui && app.ui.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode()
|
||||
}
|
||||
],
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncNodeVueBadge(node, true);
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => syncAllNodeBadges());
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
// Safely resolve circular dependencies using Dependency Injection Hooks
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
setupPropertiesPanelToggleFix();
|
||||
|
||||
setSyncQueue(syncQueue);
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
// 1. Initialize DOM Independent Services
|
||||
injectStyles();
|
||||
setupDragAndDrop();
|
||||
|
||||
// 2. Initialize UI Hierarchy
|
||||
const sidebarContainer = setupSidebarUI();
|
||||
|
||||
// 3. Connect API Hooks
|
||||
setupApiListeners();
|
||||
|
||||
// 4. Connect Storage and History Sync
|
||||
await initSessionAndHistory();
|
||||
|
||||
// 5. Connect UI Keyboard & Override Hacks
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Watch for Vue DOM node mounting events (e.g. LiteGraph ↔ Vue mode switches)
|
||||
setupVueNodeObserver();
|
||||
|
||||
// Remove any existing keydown listener from previous hot-reload
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
// Clean, direct keyboard listener
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
|
||||
if (e.key.toLowerCase() === "q") {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
// Toggle sidebar with Q
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
}, true);
|
||||
|
||||
setInterval(() => applySidebarOverride(), 500);
|
||||
|
||||
// 6. Final Registration
|
||||
// Toggle ignore node with Ctrl+Q or Cmd+Q (Mac)
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
// Apply sidebar override when initializing
|
||||
applySidebarOverride();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
@@ -60,5 +331,21 @@ app.registerExtension({
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
});
|
||||
+69
-22
@@ -1,16 +1,32 @@
|
||||
export const promptStates = new Map();
|
||||
export const cardElements = new Map();
|
||||
|
||||
// Global shared variables
|
||||
export const State = {
|
||||
currentDraggedImgData: null,
|
||||
currentSearchQuery: "",
|
||||
globalOrderCounter: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null,
|
||||
currentlyActivePromptId: null
|
||||
cardStack: null, // Main queue scroll container
|
||||
submenuStack: null, // Submenu scroll container
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null, // Active run/outputs explorer pointer
|
||||
activeSubmenuBatchImages: null // Active batch images explorer pointer
|
||||
};
|
||||
|
||||
export function deletePromptState(pid) {
|
||||
const key = String(pid);
|
||||
const state = promptStates.get(key);
|
||||
if (state) {
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
}
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
}
|
||||
}
|
||||
promptStates.delete(key);
|
||||
cardElements.delete(key);
|
||||
}
|
||||
|
||||
export function pruneHistory(app) {
|
||||
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
const tasks = Array.from(promptStates.entries())
|
||||
@@ -22,33 +38,63 @@ export function pruneHistory(app) {
|
||||
const deleteCount = tasks.length - maxItems;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
const [pid] = tasks[i];
|
||||
promptStates.delete(pid);
|
||||
cardElements.delete(pid);
|
||||
deletePromptState(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStatesToLocalStorage() {
|
||||
try {
|
||||
const serializable = [];
|
||||
let serializable = [];
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
const cleanedImages = (state.images || []).map(img => {
|
||||
if (img.url && img.url.startsWith("blob:")) return null; // Skip blob URLs
|
||||
if (img.url && img.url.startsWith("blob:")) return null;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Include workflow JSON ONLY for image-less / unfinished / failed tasks
|
||||
// so users can load or save their workflows even after browser reloads!
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
|
||||
|
||||
serializable.push({
|
||||
pid: state.pid, status: state.status, images: cleanedImages, texts: state.texts || [],
|
||||
workflow: state.workflow, progress: state.progress || 0, queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "", timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "", rendered: state.rendered || false,
|
||||
startTime: state.startTime, endTime: state.endTime, duration: state.duration
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "",
|
||||
rendered: state.rendered || false,
|
||||
startTime: state.startTime,
|
||||
endTime: state.endTime,
|
||||
duration: state.duration
|
||||
});
|
||||
}
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
try {
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function loadStatesFromLocalStorage() {
|
||||
@@ -57,11 +103,12 @@ export function loadStatesFromLocalStorage() {
|
||||
if (data) {
|
||||
const list = JSON.parse(data);
|
||||
list.forEach(state => {
|
||||
promptStates.set(state.pid, state);
|
||||
const key = String(state.pid);
|
||||
promptStates.set(key, state);
|
||||
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadStatesFromLocalStorage();
|
||||
+229
-4
@@ -1,29 +1,254 @@
|
||||
export function injectStyles() {
|
||||
if (document.getElementById("comfy-sidebar-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "comfy-sidebar-styles";
|
||||
style.textContent = `
|
||||
.comfy-sidebar-card {
|
||||
background: var(--comfy-input-bg, #181818);
|
||||
border-radius: 4px; padding: 8px; position: relative;
|
||||
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
border: 2px solid var(--border-color, #333);
|
||||
color: var(--comfy-input-color, var(--fg-color, #eee));
|
||||
}
|
||||
.comfy-sidebar-card:hover { border-color: var(--p-primary-color, var(--primary-color, #555)) !important; }
|
||||
.comfy-sidebar-card:hover {
|
||||
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
|
||||
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
|
||||
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
|
||||
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
|
||||
|
||||
.comfy-sidebar-card.pending .pi-times { display: none !important; }
|
||||
.comfy-sidebar-card.pending:hover .pi-times { display: flex !important; }
|
||||
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
|
||||
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
|
||||
display: flex !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; pointer-events: none; z-index: 5; color: #fff;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn {
|
||||
display: none;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
background-color: #7f1d1d !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 11px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease, color 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-queue-cancel-btn:hover {
|
||||
background-color: #991b1b !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Hover Panels CSS Hover Control */
|
||||
.comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-left-hover-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.comfy-sidebar-card:hover .comfy-sidebar-hover-panel,
|
||||
.comfy-sidebar-card:hover .comfy-sidebar-left-hover-panel {
|
||||
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;
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 14px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
|
||||
z-index: 20 !important;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.comfy-sidebar-card-action-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.95) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Delete Confirmation Active State */
|
||||
.comfy-sidebar-card-action-btn.confirm-delete,
|
||||
.comfy-sidebar-card-action-btn.confirm-delete:hover {
|
||||
background-color: #dc3545 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
|
||||
}
|
||||
|
||||
/* 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: 12px;
|
||||
right: 12px;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
+82
-12
@@ -1,28 +1,77 @@
|
||||
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");
|
||||
};
|
||||
|
||||
export function findImagesInOutputs(outputs) {
|
||||
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 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);
|
||||
}
|
||||
|
||||
export function findImagesInOutputs(outputs, rawWorkflow) {
|
||||
const list = [];
|
||||
if (!outputs) return list;
|
||||
for (const nodeId in outputs) {
|
||||
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);
|
||||
});
|
||||
|
||||
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) {
|
||||
export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(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)) {
|
||||
@@ -45,6 +94,26 @@ export function findTextsInOutputs(outputs) {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
|
||||
if (imgs.length > 0) {
|
||||
list.push({ nodeId, images: imgs });
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -54,8 +123,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)) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.3.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/m0rtus59/ComfyUI-ComfySidebar"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "soundslikethunder"
|
||||
DisplayName = "Comfy Sidebar"
|
||||
Reference in New Issue
Block a user