Compare commits

..
7 Commits
Author SHA1 Message Date
m0rtus59 3ccd3b6a7c Update README with new features and improvements
Added new features including a compact topbar layout and side panel button toggles.
2026-07-15 23:12:50 +05:00
m0rtus59 735cac9c97 New unified compact topbar with controls and workflow tabs. New sidebar button toggles. Minor queue UI fixes 2026-07-15 22:32:44 +05:00
m0rtus59 a9575f1a1c fixed label placement 2026-07-12 10:02:22 +05:00
m0rtus59 984cd108d6 Improve video preview 2026-07-11 20:43:41 +05:00
m0rtus59 ce2379b35c Add features for cleaning the queue 2026-07-09 00:47:39 +05:00
m0rtus59 2a975bdc9f Capped maximum text card preview length 2026-07-09 00:06:26 +05:00
m0rtus59 edd7d2f82c Refactor: Split monolithic sidebar.js into modular ES6 architecture 2026-07-08 23:51:20 +05:00
9 changed files with 1510 additions and 1516 deletions
+3 -1
View File
@@ -11,8 +11,10 @@ A lightweight, high-performance ComfyUI extension that replaces the cluttered st
* **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.
* **[NEW]** A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
* **[NEW]** Added toggles to hide buttons you don't need from the side panel.
<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
+93
View File
@@ -0,0 +1,93 @@
import { app } from "/scripts/app.js";
import { State } from "./state.js";
async function uploadDroppedImageToInput(imageObj) {
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
try {
const response = await fetch(src);
const blob = await response.blob();
const file = new File([blob], imageObj.filename || "dropped_image.png", { 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 });
const uploadData = await uploadRes.json();
return uploadData.name;
} catch(e) {
console.error("Comfy Sidebar: Failed to copy image to input.", e);
return null;
}
}
export function setupDragAndDrop() {
document.addEventListener("dragover", (e) => {
if (State.currentDraggedImgData) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
});
document.addEventListener("drop", async (e) => {
if (!State.currentDraggedImgData) 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);
}
let droppedOnImageNode = false;
if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) {
if (State.currentDraggedImgData.filename) {
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 (!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);
}
}
}
State.currentDraggedImgData = null;
}, true);
}
+349
View File
@@ -0,0 +1,349 @@
// layout.js - Variant 1 (Media Queries) with Properties Panel Toggle Fix, Indicator Filter & Sidebar Visibility
import { app } from "/scripts/app.js";
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
position: fixed !important;
top: -5px !important;
right: 4px !important;
left: auto !important;
transform: none !important;
z-index: 1010 !important;
/* Nuke the background rectangle, border, and shadows */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !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;
}
}
`;
export function applyClassicLayout(enable) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
} else {
if (styleEl) styleEl.remove();
if (app.ui && app.ui.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
/* --- Properties Panel Toggle Inline Correction --- */
let savedButtonData = null;
function findOriginalButton() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
if (icon) {
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
return btn;
}
}
return null;
}
function findTopbarContainer() {
const container = document.querySelector('[class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
if (container) return container;
const orig = findOriginalButton();
if (orig && orig.parentNode) return orig.parentNode;
const buttons = document.querySelectorAll("button, .p-button");
for (const btn of buttons) {
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
if (title.includes("run") || title.includes("queue") || title.includes("manager")) {
if (btn.parentNode) return btn.parentNode;
}
}
return null;
}
function isPropertiesPanelOpen() {
const icon = document.querySelector('[class*="lucide--panel-right"], [class*="lucide--panel-left"]');
return !!(icon && icon.className.includes("lucide--panel-left"));
}
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 updateSidebarTabsVisibility() {
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
if (!sidebar) return;
// Direct mapping from tab settings to their unique class-based HTML icon tags
const tabIconSelectors = {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"NodesMap": '[class*="pi-sitemap"]',
"Templates": '[class*="comfy--template"]'
};
Object.entries(tabIconSelectors).forEach(([tab, selector]) => {
const shouldHide = app.ui?.settings?.getSettingValue(`Comfy Sidebar.Hide Sidebar Tabs.${tab}`) ?? false;
// Find the specific icon element inside the sidebar
const icon = sidebar.querySelector(selector);
if (icon) {
// Find its closest parent button or tab item wrapper
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");
}
}
}
});
// 4. Handle "Override Stock Job History Tab" using its exact icon selector
const hideStockHistory = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Sidebar Tabs.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");
}
}
}
}
export function setupPropertiesPanelToggleFix() {
const style = document.createElement("style");
style.textContent = `
/* Hide native button completely */
.comfy-sidebar-hide-original-properties-btn {
display: none !important;
}
/* Spacing when the properties panel is CLOSED */
.comfy-sidebar-custom-properties-toggle {
margin-left: -8px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important; /* Forces perfect centering of the icon */
}
/* Spacing when the properties panel is OPEN */
.comfy-sidebar-custom-properties-toggle.comfy-panel-open {
margin-left: 0px !important;
margin-right: 0px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important; /* Forces perfect centering of the icon */
}
`;
document.head.appendChild(style);
function frameLoop() {
const isClassicLayoutEnabled = app.ui?.settings?.getSettingValue("Comfy Sidebar.Classic Layout") ?? false;
// If Classic Layout is disabled, cleanly restore stock elements and exit early
if (!isClassicLayoutEnabled) {
const originalBtn = findOriginalButton();
if (originalBtn && 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();
requestAnimationFrame(frameLoop);
return;
}
// --- Active Classic Layout Toggle Correction ---
const originalBtn = findOriginalButton();
const container = findTopbarContainer();
const openState = isPropertiesPanelOpen();
// 1. Capture original template data when native button is alive
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");
// 2. Build and insert our custom button inside topbar
if (!customBtn && savedButtonData && container) {
customBtn = document.createElement(savedButtonData.tagName);
customBtn.className = savedButtonData.className + " comfy-sidebar-custom-properties-toggle";
customBtn.innerHTML = savedButtonData.innerHTML;
for (const attr of savedButtonData.attributes) {
if (attr.name !== "class" && attr.name !== "id" && attr.name !== "style") {
customBtn.setAttribute(attr.name, attr.value);
}
}
// Standard inline rendering
Object.assign(customBtn.style, {
position: "relative",
zIndex: "1000",
margin: "0",
boxSizing: "border-box",
opacity: "1",
pointerEvents: "auto",
display: "inline-flex"
});
customBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const nativeBtn = findOriginalButton();
if (nativeBtn) nativeBtn.click();
});
container.appendChild(customBtn);
} else if (customBtn && container && container.lastChild !== customBtn) {
container.appendChild(customBtn);
}
// 3. Keep the state-specific CSS classes synchronized
if (customBtn) {
if (openState) {
if (!customBtn.classList.contains("comfy-panel-open")) {
customBtn.classList.add("comfy-panel-open");
}
} else {
if (customBtn.classList.contains("comfy-panel-open")) {
customBtn.classList.remove("comfy-panel-open");
}
}
customBtn.style.display = "inline-flex";
}
// 4. Handle "0 active" indicator visibility based on user settings (Sub-grouped Path)
const hideQueueIndicator = app.ui?.settings?.getSettingValue("Comfy Sidebar.Hide Sidebar Tabs.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");
}
}
}
// 5. Update Sidebar tab elements visibility
updateSidebarTabsVisibility();
// Run checking loop continuously synced with screen refresh cycles
requestAnimationFrame(frameLoop);
}
// Start loop synced with the screen rendering rate
requestAnimationFrame(frameLoop);
}
+215
View File
@@ -0,0 +1,215 @@
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";
export let renderDOMFn = () => {};
export let updateSidebarBadgeFn = () => {};
export function setUIDependencies(renderFn, badgeFn) {
renderDOMFn = renderFn;
updateSidebarBadgeFn = badgeFn;
}
export async function syncQueue() {
try {
const q = await api.getQueue();
const runningList = q.Running || q.queue_running || [];
const pendingList = q.Pending || q.queue_pending || [];
const pendingIds = new Set();
const normalizedPending = pendingList.map((p, idx) => {
let pid = null, seq = idx;
if (Array.isArray(p)) {
seq = typeof p[0] === 'number' ? p[0] : idx;
pid = p[1];
} else if (p && typeof p === "object") {
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 };
});
normalizedPending.sort((a, b) => a.seq - b.seq);
normalizedPending.forEach((item, index) => {
const pid = item.pid;
const number = index + 1;
if (pid) {
pendingIds.add(pid);
if (!promptStates.has(pid)) {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "pending", images: [], progress: 0, queueNumber: number,
progressText: `Pending... (#${number})`, timestamp: State.globalOrderCounter,
workflow: app.graph.serialize()
});
} else {
const st = promptStates.get(pid);
if (st.status === "pending") {
st.queueNumber = number;
st.progressText = `Pending... (#${number})`;
}
}
}
});
for (const [pid, state] of promptStates.entries()) {
if (state.status === "pending" && !pendingIds.has(pid)) promptStates.delete(pid);
}
updateSidebarBadgeFn(pendingIds.size + (runningList.length > 0 ? 1 : 0));
renderDOMFn();
} catch (err) {
console.error("Comfy Sidebar: Failed to sync queue state", err);
}
}
const concludeRun = async (pid, statusStr) => {
if (!pid || !promptStates.has(pid)) return;
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
const st = promptStates.get(pid);
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) {}
pruneHistory(app);
syncQueue();
};
export function setupApiListeners() {
api.addEventListener("status", 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()) {
if (s.status === "cancelled" || s.status === "error") {
toDelete.push(p);
promptStates.delete(p);
}
}
if (toDelete.length > 0) {
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
}
}
const pid = e.detail.prompt_id;
State.currentlyActivePromptId = pid;
const activeWorkspaceWorkflow = app.graph.serialize();
if (promptStates.has(pid)) {
const st = promptStates.get(pid);
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
st.rendered = false; st.startTime = Date.now(); st.duration = null;
} else {
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid, status: "active", images: [], progress: 0,
progressText: "Sampling...", timestamp: State.globalOrderCounter,
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
syncQueue();
});
api.addEventListener("progress", (e) => {
const pid = e.detail.prompt_id;
if (pid && promptStates.has(pid)) {
promptStates.get(pid).progress = Math.round((e.detail.value / e.detail.max) * 100);
renderDOMFn();
}
});
api.addEventListener("executing", (e) => {
const nodeId = e.detail;
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
const st = promptStates.get(State.currentlyActivePromptId);
if (nodeId) {
const node = app.graph.getNodeById(nodeId);
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
} else {
st.activeNodeName = "Finishing...";
}
renderDOMFn();
}
});
api.addEventListener("b_preview", (e) => {
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
if (activeTasks.length > 0) {
const st = activeTasks[0];
if (st._previewBlobUrl) try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
st._previewBlobUrl = URL.createObjectURL(e.detail);
st.images = [{ url: st._previewBlobUrl }];
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 });
renderDOMFn();
}
});
api.addEventListener("execution_success", (e) => concludeRun(e.detail.prompt_id, "completed"));
api.addEventListener("execution_error", (e) => concludeRun(e.detail.prompt_id, "error"));
api.addEventListener("execution_interrupted", () => {
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled"));
syncQueue();
});
}
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 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 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
});
});
pruneHistory(app);
await syncQueue();
}
+72 -1515
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
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
};
export function pruneHistory(app) {
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
const tasks = Array.from(promptStates.entries())
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
tasks.sort((a, b) => a[1].timestamp - b[1].timestamp);
if (tasks.length > maxItems) {
const deleteCount = tasks.length - maxItems;
for (let i = 0; i < deleteCount; i++) {
const [pid] = tasks[i];
promptStates.delete(pid);
cardElements.delete(pid);
}
}
}
export function saveStatesToLocalStorage() {
try {
const 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
return img;
}).filter(Boolean);
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
});
}
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
} catch (e) {
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
}
}
export function loadStatesFromLocalStorage() {
try {
const data = localStorage.getItem("comfy_sidebar_prompt_states");
if (data) {
const list = JSON.parse(data);
list.forEach(state => {
promptStates.set(state.pid, state);
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
});
}
} catch (e) {
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
}
}
+58
View File
@@ -0,0 +1,58 @@
// styles.js
export function injectStyles() {
const style = document.createElement("style");
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.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 .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;
}
/* --- Custom Queue Cancel Button to Mimic ComfyUI Native Destructive Button --- */
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important; /* Native deep red */
color: #e2e8f0 !important; /* Native slate white icon */
font-size: 11px !important; /* Muted native cross size */
border-radius: 6px !important; /* Native corner rounding */
cursor: pointer !important;
transition: background-color 0.15s ease, color 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
background-color: #991b1b !important; /* Slightly brighter red on hover */
color: #ffffff !important;
}
`;
document.head.appendChild(style);
}
+582
View File
@@ -0,0 +1,582 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
import { isVideoFormat, matchesFilter } from "./utils.js";
// DI Hook for cyclic imports
export let syncQueueFn = async () => {};
export function setSyncQueue(fn) { syncQueueFn = fn; }
export function showFullscreenPreview(imgSrcs) {
if (!imgSrcs || imgSrcs.length === 0) return;
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
background: "rgba(0,0,0,0.9)", zIndex: "10000", display: "flex",
flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "zoom-out"
});
const content = document.createElement("div");
Object.assign(content.style, { maxWidth: "90%", maxHeight: "85%", display: "flex", justifyContent: "center" });
imgSrcs.forEach(src => {
if (isVideoFormat(src)) {
const video = document.createElement("video");
video.src = src; video.autoplay = true; video.controls = true; video.loop = true;
video.style.maxWidth = "100%"; video.style.maxHeight = "100%";
content.appendChild(video);
} else {
const img = document.createElement("img");
img.src = src; img.style.maxWidth = "100%"; img.style.maxHeight = "100%"; img.style.objectFit = "contain";
content.appendChild(img);
}
});
overlay.appendChild(content);
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
export const updateSidebarBadge = (count) => {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn) {
let badge = btn.querySelector('.comfy-sidebar-badge');
if (count > 0) {
if (!badge) {
btn.style.position = 'relative';
badge = document.createElement('div');
badge.className = 'comfy-sidebar-badge';
Object.assign(badge.style, {
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
});
btn.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
} else if (badge) {
badge.remove();
}
}
});
};
export const findOurSidebarButton = () => {
const icon = document.querySelector('.pi-images');
return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null;
};
export const findStandardQueueButton = () => {
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) {
const icon = document.querySelector(iconSelector);
if (icon) {
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
if (btn && !btn.querySelector('.pi-images')) return btn;
}
}
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
for (const btn of buttons) {
const title = btn.title || btn.getAttribute('aria-label') || '';
if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) && !btn.querySelector('.pi-images') && !btn.id?.includes('classic-comfy-sidebar')) return btn;
}
return null;
};
export const applySidebarOverride = () => {
const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Sidebar Tabs.Override Stock Job History Tab") ?? false;
const stdBtn = findStandardQueueButton();
const ourBtn = findOurSidebarButton();
if (stdBtn) {
if (!stdBtn._originalDisplay) stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
if (overrideStock) {
stdBtn.style.setProperty("display", "none", "important");
if (!stdBtn._overrideClickListener) {
stdBtn._overrideClickListener = (e) => {
e.preventDefault(); e.stopPropagation();
const b = findOurSidebarButton();
if (b) b.click();
};
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
}
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
} else {
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
if (stdBtn._overrideClickListener) {
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
stdBtn._overrideClickListener = null;
}
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
stdBtn.parentNode.appendChild(ourBtn);
}
}
}
};
export function setupSidebarUI() {
State.sidebarContainer = document.createElement("div");
Object.assign(State.sidebarContainer.style, {
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)"
});
const header = document.createElement("div");
Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" });
const standardHeader = document.createElement("div");
Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
const titleGroup = document.createElement("div");
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search";
searchIcon.title = "Search History";
Object.assign(searchIcon.style, { cursor: "pointer", fontSize: "13px", opacity: "0.6", transition: "opacity 0.15s ease-in-out" });
searchIcon.onmouseenter = () => searchIcon.style.opacity = "1";
searchIcon.onmouseleave = () => searchIcon.style.opacity = "0.6";
const title = document.createElement("h3");
title.textContent = "Queue";
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
titleGroup.appendChild(searchIcon);
titleGroup.appendChild(title);
standardHeader.appendChild(titleGroup);
// Dynamic Compact Actions Group
const actionsGroup = document.createElement("div");
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
const btn = document.createElement("button");
btn.className = iconClass;
btn.title = tooltip;
Object.assign(btn.style, {
background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)",
borderRadius: "3px", padding: "4px 8px", cursor: "pointer", fontSize: "13px", transition: "all 0.15s ease-in-out"
});
let timeout = null, isPending = false;
const reset = () => {
isPending = false;
Object.assign(btn.style, { color: "var(--desc-color, #aaa)", background: "transparent", borderColor: "var(--border-color, #555)", boxShadow: "none" });
if (timeout) { clearTimeout(timeout); timeout = null; }
};
btn.onmouseenter = () => { if (!isPending) { btn.style.borderColor = "var(--fg-color, #eee)"; btn.style.color = "var(--fg-color, #eee)"; } };
btn.onmouseleave = () => { if (!isPending) { btn.style.borderColor = "var(--border-color, #555)"; btn.style.color = "var(--desc-color, #aaa)"; } };
btn.onclick = async (ev) => {
ev.stopPropagation();
if (!isPending) {
isPending = true;
Object.assign(btn.style, { color: "#fff", background: hoverColor, borderColor: hoverColor, boxShadow: `0 0 8px ${hoverColor}80` });
timeout = setTimeout(reset, 1500); // 1.5s to confirm
} else {
reset();
await onClickFn();
}
};
return btn;
};
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
const toDelete = [];
for (const [pid, state] of promptStates.entries()) {
if (state.status === "cancelled" || state.status === "error") {
toDelete.push(pid);
promptStates.delete(pid);
}
}
if (toDelete.length > 0) {
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }); } catch (err) {}
renderDOM();
}
});
const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => {
for (const [pid, state] of promptStates.entries()) {
if (state.status !== "pending" && state.status !== "active") {
promptStates.delete(pid);
}
}
try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clear: true }) }); } catch (err) {}
renderDOM();
});
actionsGroup.appendChild(btnClearInterrupted);
actionsGroup.appendChild(btnClearAll);
standardHeader.appendChild(actionsGroup);
const searchContainer = document.createElement("div");
Object.assign(searchContainer.style, { display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)", border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px", boxSizing: "border-box", height: "26px" });
const searchInputIcon = document.createElement("span");
searchInputIcon.className = "pi pi-search";
Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" });
const searchInput = document.createElement("input");
Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." });
Object.assign(searchInput.style, { flex: "1", background: "transparent", border: "none", outline: "none", color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0" });
const clearSearchBtn = document.createElement("span");
clearSearchBtn.className = "pi pi-times";
clearSearchBtn.title = "Clear & Close Search";
Object.assign(clearSearchBtn.style, { cursor: "pointer", fontSize: "11px", opacity: "0.6", marginLeft: "6px", transition: "opacity 0.15s ease" });
clearSearchBtn.onmouseenter = () => clearSearchBtn.style.opacity = "1"; clearSearchBtn.onmouseleave = () => clearSearchBtn.style.opacity = "0.6";
searchContainer.appendChild(searchInputIcon); searchContainer.appendChild(searchInput); searchContainer.appendChild(clearSearchBtn);
header.appendChild(standardHeader); header.appendChild(searchContainer); State.sidebarContainer.appendChild(header);
searchIcon.onclick = (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); };
const closeSearch = () => { searchInput.value = ""; State.currentSearchQuery = ""; searchContainer.style.display = "none"; standardHeader.style.display = "flex"; renderDOM(); };
clearSearchBtn.onclick = (e) => { e.stopPropagation(); closeSearch(); };
searchInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); };
searchInput.oninput = () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); };
State.cardStack = document.createElement("div");
Object.assign(State.cardStack.style, { flex: "1", overflowY: "auto", scrollbarWidth: "thin", display: "block" });
State.sidebarContainer.appendChild(State.cardStack);
new ResizeObserver((entries) => {
const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350;
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
State.cardStack.style.columnCount = cols.toString();
State.cardStack.style.columnGap = cols > 1 ? "12px" : "0";
}).observe(State.sidebarContainer);
setInterval(() => {
for (const [pid, state] of promptStates.entries()) {
if (state.status === "active" && state.startTime) {
const cardObj = cardElements.get(pid);
if (cardObj && cardObj.timerEl) cardObj.timerEl.textContent = ((Date.now() - state.startTime) / 1000).toFixed(2) + "s";
}
}
}, 100);
return State.sidebarContainer;
}
let renderTimeout = null;
export function renderDOM() {
if (renderTimeout) cancelAnimationFrame(renderTimeout);
renderTimeout = requestAnimationFrame(() => {
const showPendingSummary = app.ui.settings.getSettingValue("Comfy Sidebar.Show Pending Count Only") ?? true;
const keepAspect = app.ui.settings.getSettingValue("Comfy Sidebar.Keep Object Aspect Ratio") ?? true;
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
let tasksArray = Array.from(promptStates.values());
if (showPendingSummary) tasksArray = tasksArray.filter(t => t.status !== "pending");
if (State.currentSearchQuery) tasksArray = tasksArray.filter(t => matchesFilter(t, State.currentSearchQuery));
tasksArray.sort((a, b) => b.timestamp - a.timestamp);
const activeTasks = tasksArray.filter(t => t.status === "active");
const completedTasks = tasksArray.filter(t => t.status === "completed" || t.status === "cancelled" || t.status === "error");
const pendingTasks = tasksArray.filter(t => t.status === "pending").sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0));
const syncCardElement = (state) => {
let cardObj = cardElements.get(state.pid);
const isFinalStatus = state.status === "completed" || state.status === "cancelled" || state.status === "error";
if (cardObj && isFinalStatus && state.rendered) return cardObj.element;
if (!cardObj) {
const card = document.createElement("div");
const timerEl = document.createElement("div"); timerEl.className = "comfy-sidebar-card-timer";
const cancelX = document.createElement("span");
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
Object.assign(cancelX.style, {
position: "absolute",
top: "4px",
right: "4px",
display: "none",
zIndex: "10"
});
const sBadge = document.createElement("div");
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none", zIndex: "10" });
const grid = document.createElement("div"); grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
const p = document.createElement("div");
Object.assign(p.style, {
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none",
display: "-webkit-box", WebkitLineClamp: "15", WebkitBoxOrient: "vertical", overflow: "hidden", wordBreak: "break-word"
});
const pt = document.createElement("div"); Object.assign(pt.style, { width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px", overflow: "hidden", display: "none" });
const pb = document.createElement("div"); Object.assign(pb.style, { width: `0%`, height: "100%", background: "#3b82f6", transition: "width 0.1s linear" });
pt.appendChild(pb);
const statusText = document.createElement("div"); Object.assign(statusText.style, { fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px", display: "none", fontWeight: "bold" });
const hoverPanel = document.createElement("div"); Object.assign(hoverPanel.style, { position: "absolute", bottom: "4px", right: "4px", display: "none", gap: "12px", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", zIndex: "20" });
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image"; Object.assign(btnImg.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file"; Object.assign(btnJson.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" });
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash";
Object.assign(btnDel.style, {
cursor: "pointer",
fontSize: "20px",
color: "#dc3545",
transition: "all 0.1s ease-in-out",
padding: "2px",
transform: "scale(1)"
});
hoverPanel.append(btnImg, btnJson, btnDel);
card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel);
cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" };
card.addEventListener("mouseenter", () => { if (state.status !== "active" && state.status !== "pending") cardObj.hoverPanel.style.display = "flex"; });
card.addEventListener("mouseleave", () => cardObj.hoverPanel.style.display = "none");
card.addEventListener("dragstart", (e) => {
if (cardObj.firstImgElement) e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15);
if (state.images && state.images[0]) {
State.currentDraggedImgData = { ...state.images[0], workflow: state.workflow };
const img = state.images[0];
const fileUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
try { e.dataTransfer.setData("DownloadURL", `image/png:${img.filename || "output.png"}:${fileUrl}`); e.dataTransfer.setData("text/uri-list", fileUrl); e.dataTransfer.setData("text/plain", fileUrl); e.dataTransfer.items.add(new File([""], img.filename || "output.png", { type: "image/png" })); } catch (err) {}
} else if (state.workflow) {
State.currentDraggedImgData = { workflow: state.workflow };
const jsonStr = JSON.stringify(state.workflow, null, 2);
try { e.dataTransfer.setData("DownloadURL", `application/json:workflow_${state.pid}.json:data:application/json;base64,` + btoa(unescape(encodeURIComponent(jsonStr)))); e.dataTransfer.setData("text/plain", jsonStr); e.dataTransfer.setData("application/json", jsonStr); } catch (err) {}
}
e.dataTransfer.effectAllowed = "copy";
});
cardElements.set(state.pid, cardObj);
}
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
cardObj.element.setAttribute("draggable", "true");
if (state.status === "active") {
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
cardObj.timerEl.style.display = "block";
} else if (state.duration !== undefined && state.duration !== null) {
cardObj.timerEl.textContent = state.duration.toFixed(2) + "s"; cardObj.timerEl.style.display = "block";
} else cardObj.timerEl.style.display = "none";
if (state.status === "cancelled") Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" });
else if (state.status === "error") Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" });
else cardObj.statusBadge.style.display = "none";
if (state.status === "cancelled") cardObj.statusBadge.textContent = "Cancelled";
else if (state.status === "error") cardObj.statusBadge.textContent = "Error";
if (state.status === "pending" && !showPendingSummary) {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
await syncQueueFn();
};
} else if (state.status === "active") {
cardObj.cancelBtn.style.display = "flex";
cardObj.cancelBtn.onclick = async (ev) => {
ev.stopPropagation();
await api.interrupt(); // Natively interrupts current execution
await syncQueueFn();
};
} else {
cardObj.cancelBtn.style.display = "none";
}
if (state.images && state.images.length > 0) {
cardObj.btnImg.style.display = "inline";
cardObj.btnImg.onclick = (ev) => { ev.stopPropagation(); state.images.forEach(img => { const a = document.createElement("a"); a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; a.download = img.filename || "output"; a.click(); }); };
} else cardObj.btnImg.style.display = "none";
if (state.workflow) {
cardObj.btnJson.style.display = "inline";
cardObj.btnJson.onclick = (ev) => { ev.stopPropagation(); const blob = new Blob([JSON.stringify(state.workflow, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `workflow_${state.pid}.json`; a.click(); URL.revokeObjectURL(url); };
} else cardObj.btnJson.style.display = "none";
let deleteTimeout = null, isDeletePending = false;
const resetDeleteBtn = () => {
isDeletePending = false;
Object.assign(cardObj.btnDel.style, {
color: "#dc3545",
background: "none",
padding: "2px",
transform: "scale(1)"
});
cardObj.btnDel.title = "Delete Element from History";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
cardObj.btnDel.onclick = async (ev) => {
ev.stopPropagation();
if (!isDeletePending) {
isDeletePending = true;
Object.assign(cardObj.btnDel.style, {
color: "#fff",
background: "#dc3545",
borderRadius: "4px",
padding: "2px",
transform: "scale(1.2)"
});
cardObj.btnDel.title = "Click again to confirm deletion";
deleteTimeout = setTimeout(resetDeleteBtn, 1000);
} else {
resetDeleteBtn();
promptStates.delete(state.pid);
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
renderDOM();
}
};
const currentImagesSignature = state.images ? state.images.map(img => img.url || img.filename).join("|") : "";
if (cardObj.lastImagesSignature !== currentImagesSignature) {
if (!state.images || state.images.length === 0) {
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null; cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
// Check if we can reuse elements (count matches AND tag types match)
let canReuse = cardObj.grid.children.length === state.images.length;
if (canReuse) {
state.images.forEach((img, idx) => {
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
const wrapper = cardObj.grid.children[idx];
// Fallback in case the DOM still has old unwrapped elements
const mediaEl = (wrapper.tagName === "IMG" || wrapper.tagName === "VIDEO") ? wrapper : wrapper.querySelector("img, video");
if (!mediaEl || isVideoFormat(src) !== (mediaEl.tagName.toLowerCase() === "video")) canReuse = false;
});
}
if (canReuse) {
state.images.forEach((img, idx) => {
let src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
const isVideo = isVideoFormat(src);
if (isVideo) src = src + "#t=0.001"; // Keep the first-frame trick on update
const wrapper = cardObj.grid.children[idx];
const mediaEl = (wrapper.tagName === "IMG" || wrapper.tagName === "VIDEO") ? wrapper : wrapper.querySelector("img, video");
if (mediaEl.getAttribute("src") !== src && mediaEl.src !== src) {
if (src.startsWith("blob:") && !isVideo) {
const tempImg = new Image();
tempImg.onload = () => {
const oldBlob = mediaEl._lastBlob;
mediaEl.src = src;
mediaEl._lastBlob = src;
if (oldBlob && oldBlob !== src) try { URL.revokeObjectURL(oldBlob); } catch(e){}
};
tempImg.src = src;
}
else mediaEl.src = src;
}
});
} else {
cardObj.grid.innerHTML = ""; cardObj.firstImgElement = null;
state.images.forEach(img => {
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`;
const isVideo = isVideoFormat(src);
// Container to hold both the media and the play icon
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
const thumb = isVideo ? document.createElement("video") : document.createElement("img");
Object.assign(thumb.style, { width: "100%", borderRadius: "2px", display: "block", cursor: "zoom-in" });
if (isVideo) {
thumb.muted = true;
thumb.playsInline = true;
thumb.preload = "metadata";
thumb.loop = true;
// Create the Play Icon overlay with perfectly centered scalable SVG
const playIcon = document.createElement("div");
Object.assign(playIcon.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
pointerEvents: "none", zIndex: "5", transition: "opacity 0.2s ease"
});
// The radius 16.65 ensures the circle is exactly 1/3 of the shorter edge.
// The polygon coordinates are an optically centered equilateral triangle.
playIcon.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
</svg>
`;
wrapper.appendChild(playIcon);
// Play on Hover, Pause on Leave, and fade out the play button
thumb.onmouseenter = () => { playIcon.style.opacity = "0"; thumb.play().catch(()=>{}); };
thumb.onmouseleave = () => { playIcon.style.opacity = "1"; thumb.pause(); };
}
if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; }
thumb.setAttribute("draggable", "false");
if (!isVideo) {
thumb.onload = () => { if (keepAspect && thumb.naturalWidth && thumb.naturalHeight) thumb.style.aspectRatio = `${thumb.naturalWidth} / ${thumb.naturalHeight}`; };
if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { thumb.src = src; thumb._lastBlob = src; }; tempImg.src = src; } else thumb.src = src;
} else {
thumb.onloadedmetadata = () => { if (keepAspect && thumb.videoWidth && thumb.videoHeight) thumb.style.aspectRatio = `${thumb.videoWidth} / ${thumb.videoHeight}`; };
thumb.src = src + "#t=0.001";
}
thumb.onclick = (ev) => { ev.stopPropagation(); showFullscreenPreview(state.images.map(i => i.url ? i.url : `/view?filename=${encodeURIComponent(i.filename)}&type=${i.type}&subfolder=${encodeURIComponent(i.subfolder)}`)); };
wrapper.insertBefore(thumb, wrapper.firstChild);
if (!cardObj.firstImgElement) cardObj.firstImgElement = thumb;
cardObj.grid.appendChild(wrapper);
});
}
}
cardObj.lastImagesSignature = currentImagesSignature;
}
if (state.images.length === 0) {
if (state.texts && state.texts.length > 0) {
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left" });
} else {
cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center" });
}
}
if (state.status === "active") {
if (showWorkingNode) {
cardObj.statusText.style.display = "block";
cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Sampling...${state.progress ? ` ${state.progress}%` : ""}`;
} else cardObj.statusText.style.display = "none";
cardObj.progressContainer.style.display = "block"; cardObj.progressBar.style.width = `${state.progress || 0}%`;
} else { cardObj.statusText.style.display = "none"; cardObj.progressContainer.style.display = "none"; }
if (isFinalStatus) state.rendered = true;
return cardObj.element;
};
for (const [pid, cardObj] of cardElements.entries()) { if (pid !== "pending-summary-card" && pid !== "pending-cancel-all-standalone" && !promptStates.has(pid)) cardElements.delete(pid); }
const pendingCount = Array.from(promptStates.values()).filter(t => t.status === "pending").length;
const targetElements = [];
if (pendingCount > 0) {
if (showPendingSummary) {
let pCard = cardElements.get("pending-summary-card");
if (!pCard) {
const el = document.createElement("div"); Object.assign(el.style, { background: "#181818", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px", marginBottom: "12px", textAlign: "center", fontSize: "12px", fontWeight: "bold", color: "#aaa", breakInside: "avoid", display: "flex", flexDirection: "column", gap: "8px" });
const textDiv = document.createElement("div"); el.appendChild(textDiv);
const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel All Pending"; Object.assign(cancelBtn.style, { background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px", cursor: "pointer", fontSize: "11px", fontWeight: "bold" });
cancelBtn.onclick = async () => { await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) }); await syncQueueFn(); };
el.appendChild(cancelBtn); pCard = { element: el, textDiv }; cardElements.set("pending-summary-card", pCard);
}
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
targetElements.push(pCard.element);
} else {
let btnCard = cardElements.get("pending-cancel-all-standalone");
if (!btnCard) {
const btn = document.createElement("button"); btn.textContent = "Cancel All Pending"; Object.assign(btn.style, { background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px", cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%", marginBottom: "12px", breakInside: "avoid" });
btn.onclick = async () => { await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) }); await syncQueueFn(); };
btnCard = { element: btn }; cardElements.set("pending-cancel-all-standalone", btnCard);
}
targetElements.push(btnCard.element);
pendingTasks.forEach(st => targetElements.push(syncCardElement(st)));
}
}
activeTasks.forEach(st => targetElements.push(syncCardElement(st)));
completedTasks.forEach(st => targetElements.push(syncCardElement(st)));
targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); });
while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild);
saveStatesToLocalStorage();
});
}
+71
View File
@@ -0,0 +1,71 @@
export const isVideoFormat = (url) => {
const s = url.toLowerCase();
return s.includes(".mp4") || s.includes(".webm");
};
export function findImagesInOutputs(outputs) {
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);
});
}
}
}
return list;
}
export function findTextsInOutputs(outputs) {
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 (typeof item === 'string') {
list.push(item);
} else if (item && typeof item === 'object' && item.text) {
if (Array.isArray(item.text)) list.push(...item.text);
else if (typeof item.text === 'string') list.push(item.text);
}
});
} else if (typeof val === 'string') {
list.push(val);
} else if (val && typeof val === 'object' && val.text) {
if (Array.isArray(val.text)) list.push(...val.text);
else if (typeof val.text === 'string') list.push(val.text);
}
}
}
return list;
}
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
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) {
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)) {
for (const w of node.widgets) {
if (w && w.value !== undefined && w.value !== null) {
if (String(w.value).toLowerCase().includes(q)) return true;
}
}
}
}
}
return false;
}