diff --git a/js/dragdrop.js b/js/dragdrop.js new file mode 100644 index 0000000..50a89e2 --- /dev/null +++ b/js/dragdrop.js @@ -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); +} \ No newline at end of file diff --git a/js/queue.js b/js/queue.js new file mode 100644 index 0000000..296bef8 --- /dev/null +++ b/js/queue.js @@ -0,0 +1,201 @@ +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) => { + 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(); +} \ No newline at end of file diff --git a/js/sidebar.js b/js/sidebar.js index f093ac8..ded38bb 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -1,480 +1,8 @@ import { app } from "/scripts/app.js"; -import { api } from "/scripts/api.js"; - -// --- Global Drag & Search Payloads --- -let currentDraggedImgData = null; -let currentSearchQuery = ""; -const promptStates = new Map(); -const cardElements = new Map(); - -let globalOrderCounter = 0; -let sidebarContainer = null; -let cardStack = null; -let currentlyActivePromptId = null; // Track executing ID to fix node name display - -// Inject native CSS to offload hover, borders, and theme variables to the browser's style engine -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; } - - /* Red (X) Cancel action shows up only on card hover */ - .comfy-sidebar-card.pending .pi-times { - display: none !important; - } - .comfy-sidebar-card.pending:hover .pi-times { - display: flex !important; - } - - /* Small timer style in the top-left corner of the card */ - .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; - } -`; -document.head.appendChild(style); - -// Helper to determine if a URL represents a video format -const isVideoFormat = (url) => { - const s = url.toLowerCase(); - return s.includes(".mp4") || s.includes(".webm"); -}; - -// Filters metadata inside the prompt structure -function matchesFilter(state, query) { - if (!query) return true; - const q = query.toLowerCase(); - - // 1. Check Prompt ID - if (state.pid && String(state.pid).toLowerCase().includes(q)) { - return true; - } - - // 2. Check Text outputs - if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) { - return true; - } - - // 3. Check Image/Video output filenames - if (state.images && state.images.some(img => { - const filename = img.filename || ""; - return filename.toLowerCase().includes(q); - })) { - return true; - } - - // 4. Check active/working node name - if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) { - return true; - } - - // 5. Structural lookup within snapshotted workflow JSON (nodes, widget values, etc.) - 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; -} - -// Prunes historical tasks from state matching the client queue history setting -function pruneHistory() { - const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64; - - // Select non-pending, non-active tasks - const tasks = Array.from(promptStates.entries()) - .filter(([pid, state]) => state.status !== "pending" && state.status !== "active"); - - // Sort ascending by timestamp (oldest first) - 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); - } - } -} - -// Creates a dark fullscreen preview overlay supporting both images and video formats -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); -} - -// --- Background Copier for LoadImage Nodes --- -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 folder.", e); - return null; - } -} - -// --- Global Targeted Drop Listener --- -document.addEventListener("dragover", (e) => { - if (currentDraggedImgData) { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - } -}); - -document.addEventListener("drop", async (e) => { - if (!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; - - // 1. If dropped on Load Image Node - if (targetNode && (targetNode.type.includes("LoadImage") || targetNode.widgets?.some(w => w.name === "image"))) { - if (currentDraggedImgData.filename) { // Only attempt node upload if drag target represents a real file - e.preventDefault(); - e.stopPropagation(); - droppedOnImageNode = true; - const widget = targetNode.widgets.find(w => w.name === "image"); - if (widget) { - const newFilename = await uploadDroppedImageToInput(currentDraggedImgData); - if (newFilename) { - widget.value = newFilename; - if (widget.callback) widget.callback(widget.value); - targetNode.imgs = null; - app.graph.setDirtyCanvas(true, true); - } - } - } - } - - // 2. If dropped on Empty Canvas (Synthesize native File drop to load workflow) - if (!droppedOnImageNode) { - e.preventDefault(); - e.stopPropagation(); - try { - if (currentDraggedImgData.workflow) { - // Direct loading for text-only/empty cards carrying workflow data - if (app.loadGraphData) { - app.loadGraphData(currentDraggedImgData.workflow); - } else if (app.handleFile) { - const jsonStr = JSON.stringify(currentDraggedImgData.workflow); - const file = new File([jsonStr], "workflow.json", { type: "application/json" }); - await app.handleFile(file); - } - } else if (currentDraggedImgData.filename) { - const src = currentDraggedImgData.url || `/view?filename=${encodeURIComponent(currentDraggedImgData.filename)}&type=${currentDraggedImgData.type || 'output'}&subfolder=${encodeURIComponent(currentDraggedImgData.subfolder || '')}`; - const res = await fetch(src); - const blob = await res.blob(); - const file = new File([blob], 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); - } - } - } - currentDraggedImgData = null; -}, true); - -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; -} - -// Scrapes and yields formatted text outputs found across active generation scopes -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; -} - -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(); - } - } - }); -}; - -// Tooling query selectors to safely find standard and custom tabs in the side rail -const findOurSidebarButton = () => { - const icon = document.querySelector('.pi-images'); - if (icon) { - return icon.closest('.comfyui-sidebar-tab, button, [role="tab"]'); - } - return null; -}; - -const findStandardQueueButton = () => { - const possibleIcons = [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]; - for (const iconSelector of possibleIcons) { - 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')) { - if (!btn.querySelector('.pi-images') && !btn.id?.includes('classic-comfy-sidebar')) { - return btn; - } - } - } - return null; -}; - -// Handles button relocation and redirection when overriding the stock tab -const applySidebarOverride = () => { - const overrideStock = app.ui.settings.getSettingValue("Comfy Sidebar.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"); - - // Redirect standard panel clicks (e.g., active generation link clicks) to our panel - if (!stdBtn._overrideClickListener) { - stdBtn._overrideClickListener = (e) => { - e.preventDefault(); - e.stopPropagation(); - const b = findOurSidebarButton(); - if (b) b.click(); - }; - stdBtn.addEventListener('click', stdBtn._overrideClickListener, true); - } - - // Move our sidebar tab button right before the hidden standard Queue tab (replaces top slot) - 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; - } - // Restore our button to the bottom of the stack - if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) { - stdBtn.parentNode.appendChild(ourBtn); - } - } - } -}; - -// Cache and Restore helper to survive page refreshes -const saveStatesToLocalStorage = () => { - try { - const serializable = []; - for (const [pid, state] of promptStates.entries()) { - // Filter out temporary blob URLs from the serialized images - const cleanedImages = (state.images || []).map(img => { - if (img.url && img.url.startsWith("blob:")) { - return null; // Skip temporary 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); - } -}; - -const 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 > globalOrderCounter) { - globalOrderCounter = state.timestamp; - } - }); - } - } catch (e) { - console.error("Comfy Sidebar: Failed to load state from localStorage", e); - } -}; +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"; app.registerExtension({ name: "ComfySidebar.ClassicRestore", @@ -490,1056 +18,44 @@ app.registerExtension({ async setup() { if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return; - sidebarContainer = document.createElement("div"); - Object.assign(sidebarContainer.style, { - display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box", - background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)" - }); + // Safely resolve circular dependencies using Dependency Injection Hooks + setSyncQueue(syncQueue); + setUIDependencies(renderDOM, updateSidebarBadge); - // --- Flexbox layout header container with fixed boundary height --- - const header = document.createElement("div"); - Object.assign(header.style, { - position: "relative", - marginBottom: "12px", - height: "26px", - display: "flex", - alignItems: "center" - }); + // 1. Initialize DOM Independent Services + injectStyles(); + setupDragAndDrop(); - // 1. Standard Header Panel (Title + Magnifying Glass + Action Trigger Button) - 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); - - const clearHistoryBtn = document.createElement("button"); - clearHistoryBtn.textContent = "Clear History"; - Object.assign(clearHistoryBtn.style, { - background: "transparent", - color: "var(--desc-color, #aaa)", - border: "1px solid var(--border-color, #555)", - borderRadius: "3px", - padding: "3px 8px", - cursor: "pointer", - fontSize: "11px", - fontWeight: "bold", - transition: "all 0.15s ease-in-out" - }); - - // Double-click confirm pattern with timeout - let clearHistoryTimeout = null; - let isClearHistoryPending = false; - - const resetClearHistoryBtn = () => { - isClearHistoryPending = false; - clearHistoryBtn.textContent = "Clear History"; - clearHistoryBtn.style.color = "var(--desc-color, #aaa)"; - clearHistoryBtn.style.background = "transparent"; - clearHistoryBtn.style.borderColor = "var(--border-color, #555)"; - clearHistoryBtn.style.boxShadow = "none"; - if (clearHistoryTimeout) { - clearTimeout(clearHistoryTimeout); - clearHistoryTimeout = null; - } - }; - - // Add visual response highlights - clearHistoryBtn.addEventListener("mouseenter", () => { - if (!isClearHistoryPending) { - clearHistoryBtn.style.borderColor = "var(--fg-color, #eee)"; - clearHistoryBtn.style.color = "var(--fg-color, #eee)"; - } - }); - clearHistoryBtn.addEventListener("mouseleave", () => { - if (!isClearHistoryPending) { - clearHistoryBtn.style.borderColor = "var(--border-color, #555)"; - clearHistoryBtn.style.color = "var(--desc-color, #aaa)"; - } - }); - - clearHistoryBtn.onclick = async (ev) => { - ev.stopPropagation(); - if (!isClearHistoryPending) { - isClearHistoryPending = true; - clearHistoryBtn.textContent = "Confirm?"; - clearHistoryBtn.style.color = "#fff"; - clearHistoryBtn.style.background = "#dc3545"; - clearHistoryBtn.style.borderColor = "#dc3545"; - clearHistoryBtn.style.boxShadow = "0 0 8px rgba(220, 53, 69, 0.6)"; // Muted glowing red shadow ring - clearHistoryTimeout = setTimeout(() => { - resetClearHistoryBtn(); - }, 1500); // 1.5 seconds confirmation window - } else { - resetClearHistoryBtn(); - - // Filter and delete only completed/error/cancelled items from promptStates - for (const [pid, state] of promptStates.entries()) { - if (state.status !== "pending" && state.status !== "active") { - promptStates.delete(pid); - cardElements.delete(pid); - } - } - - // Clear backend history - try { - await api.fetchApi("/history", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clear: true }) - }); - } catch (err) { - console.error("Comfy Sidebar: Failed to clear backend history:", err); - } - renderDOM(); - } - }; - - standardHeader.appendChild(clearHistoryBtn); - - // 2. Search Container (Hidden by default, overlays standard header when active) - 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"); - searchInput.type = "text"; - searchInput.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); - sidebarContainer.appendChild(header); - - // --- Search Overlay Transition Bindings --- - searchIcon.onclick = (e) => { - e.stopPropagation(); - standardHeader.style.display = "none"; - searchContainer.style.display = "flex"; - searchInput.focus(); - }; - - const closeSearch = () => { - searchInput.value = ""; - 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 = () => { - currentSearchQuery = searchInput.value.trim(); - renderDOM(); - }; - - cardStack = document.createElement("div"); - Object.assign(cardStack.style, { flex: "1", overflowY: "auto", scrollbarWidth: "thin", display: "block" }); - sidebarContainer.appendChild(cardStack); - - const resizeObserver = new ResizeObserver((entries) => { - const width = entries[0].contentRect.width; - const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350; - const cols = Math.max(1, Math.floor(width / (threshold / 2))); - cardStack.style.columnCount = cols.toString(); - cardStack.style.columnGap = cols > 1 ? "12px" : "0"; - }); - resizeObserver.observe(sidebarContainer); - - // Continuous high-performance update loop for active card timers - setInterval(() => { - for (const [pid, state] of promptStates.entries()) { - if (state.status === "active" && state.startTime) { - const cardObj = cardElements.get(pid); - if (cardObj && cardObj.timerEl) { - const elapsed = (Date.now() - state.startTime) / 1000; - cardObj.timerEl.textContent = elapsed.toFixed(2) + "s"; - } - } - } - }, 100); - - // --- NON-DESTRUCTIVE DOM RENDERER --- - let renderTimeout = null; - const 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"); - } - - // Apply active text and metadata filters - if (currentSearchQuery) { - tasksArray = tasksArray.filter(t => matchesFilter(t, currentSearchQuery)); - } - - // Sorting: Force newest items at the top of the queue - 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"); - - // Newest pending items always on top - pendingTasks.sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0)); - - const syncCardElement = (state) => { - let cardObj = cardElements.get(state.pid); - - // Task-Level Render Isolation: skip rendering if card is already finalized/cached - 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"); - card.className = `comfy-sidebar-card ${state.status}`; - - // Dedicated corner timer element - const timerEl = document.createElement("div"); - timerEl.className = "comfy-sidebar-card-timer"; - card.appendChild(timerEl); - - // Cancel Task (X) Trigger (Unified size + underlayer) - const cancelX = document.createElement("span"); - cancelX.className = "pi pi-times"; - cancelX.title = "Cancel Pending Task"; - Object.assign(cancelX.style, { - position: "absolute", top: "4px", right: "4px", color: "#dc3545", cursor: "pointer", - fontSize: "20px", display: "none", zIndex: "10", - background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", - transition: "color 0.2s" - }); - card.appendChild(cancelX); - - // Status overlay badge - 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" - }); - card.appendChild(sBadge); - - const grid = document.createElement("div"); - Object.assign(grid.style, { display: "flex", flexDirection: "column", gap: "6px" }); - card.appendChild(grid); - - const p = document.createElement("div"); - Object.assign(p.style, { fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none", webkitUserSelect: "none" }); - card.appendChild(p); - - 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); - card.appendChild(pt); - - // Permanent, Dedicated Active Status Text container to resolve visual overlay issues - const statusText = document.createElement("div"); - Object.assign(statusText.style, { - fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", - marginTop: "6px", display: "none", fontWeight: "bold" - }); - card.appendChild(statusText); - - // Floating Action Hover Panel (Unified dimensions with cancel panel) - 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" - }); - - // Unified 20px UI action buttons - const btnImg = document.createElement("span"); - btnImg.className = "pi pi-image"; - btnImg.title = "Save Object"; - Object.assign(btnImg.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" }); - - const btnJson = document.createElement("span"); - btnJson.className = "pi pi-file"; - btnJson.title = "Save Workflow"; - Object.assign(btnJson.style, { cursor: "pointer", fontSize: "20px", color: "#aaa" }); - - const btnDel = document.createElement("span"); - btnDel.className = "pi pi-trash"; - btnDel.title = "Delete Element"; - Object.assign(btnDel.style, { cursor: "pointer", fontSize: "20px", color: "#dc3545", transition: "all 0.1s ease-in-out" }); - - hoverPanel.appendChild(btnImg); - hoverPanel.appendChild(btnJson); - hoverPanel.appendChild(btnDel); - card.appendChild(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 events - 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]) { - // Capture workflow alongside the image metadata to guarantee dropped cancelled previews load graphs - 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)}`; - const filename = img.filename || "output.png"; - const mimeType = "image/png"; - - try { - e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fileUrl}`); - e.dataTransfer.setData("text/uri-list", fileUrl); - e.dataTransfer.setData("text/plain", fileUrl); - } catch (err) { - console.warn("Comfy Sidebar: Failed to set dragstart dataTransfer data:", err); - } - - // Dummy file to trigger native LoadImage highlights - try { - const dummyFile = new File([""], filename, { type: mimeType }); - e.dataTransfer.items.add(dummyFile); - } catch (err) { - // Silently catch environment limits - } - } else if (state.workflow) { - currentDraggedImgData = { workflow: state.workflow }; - - // Base64 data URL bypasses secure network limitations to allow clean local desktop downloads - const jsonStr = JSON.stringify(state.workflow, null, 2); - const dataUrl = "data:application/json;base64," + btoa(unescape(encodeURIComponent(jsonStr))); - const filename = `workflow_${state.pid}.json`; - - try { - e.dataTransfer.setData("DownloadURL", `application/json:${filename}:${dataUrl}`); - e.dataTransfer.setData("text/plain", jsonStr); - e.dataTransfer.setData("application/json", jsonStr); - } catch (err) { - console.warn("Comfy Sidebar: Failed to set workflow JSON dataTransfer data:", err); - } - } - e.dataTransfer.effectAllowed = "copy"; - }); - - cardElements.set(state.pid, cardObj); - } - - // Keep selector matching in sync with current state status - cardObj.element.className = `comfy-sidebar-card ${state.status}`; - // Restore HTML5 draggable attribute dynamically to resolve drag-and-drop bug - cardObj.element.setAttribute("draggable", "true"); - - // Set visual output for the timer - if (state.status === "active") { - if (state.startTime) { - const elapsed = (Date.now() - state.startTime) / 1000; - cardObj.timerEl.textContent = elapsed.toFixed(2) + "s"; - } else { - cardObj.timerEl.textContent = "..."; - } - 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"; - } - - // Text overlay badges for Cancelled/Error runs - if (state.status === "cancelled") { - cardObj.statusBadge.style.display = "block"; - cardObj.statusBadge.textContent = "Cancelled"; - cardObj.statusBadge.style.background = "#ffc107"; - cardObj.statusBadge.style.color = "#000"; - } else if (state.status === "error") { - cardObj.statusBadge.style.display = "block"; - cardObj.statusBadge.textContent = "Error"; - cardObj.statusBadge.style.background = "#dc3545"; - cardObj.statusBadge.style.color = "#fff"; - } else { - cardObj.statusBadge.style.display = "none"; - } - - // Cancel pending action bindings - 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 syncQueue(); - }; - } else { - cardObj.cancelBtn.style.display = "none"; - } - - // Action buttons behavior bindings - if (state.images && state.images.length > 0) { - cardObj.btnImg.style.display = "inline"; - cardObj.btnImg.onclick = (ev) => { - ev.stopPropagation(); - state.images.forEach(img => { - const fileUrl = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; - const a = document.createElement("a"); - a.href = fileUrl; - 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 jsonStr = JSON.stringify(state.workflow, null, 2); - const blob = new Blob([jsonStr], { 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"; - } - - // Confirm-before-delete UI behavior logic - let deleteTimeout = null; - let isDeletePending = false; - - const resetDeleteBtn = () => { - isDeletePending = false; - cardObj.btnDel.style.color = "#dc3545"; - cardObj.btnDel.style.background = "none"; - cardObj.btnDel.style.padding = "0"; - cardObj.btnDel.title = "Delete Element from History"; - if (deleteTimeout) { - clearTimeout(deleteTimeout); - deleteTimeout = null; - } - }; - - cardObj.btnDel.onclick = async (ev) => { - ev.stopPropagation(); - if (!isDeletePending) { - // First click: arm the deletion visually - isDeletePending = true; - cardObj.btnDel.style.color = "#fff"; - cardObj.btnDel.style.background = "#dc3545"; - cardObj.btnDel.style.borderRadius = "4px"; - cardObj.btnDel.style.padding = "2px"; - cardObj.btnDel.title = "Click again to confirm deletion"; - - deleteTimeout = setTimeout(() => { - resetDeleteBtn(); - }, 1000); // 1 second confirmation window - } else { - // Second click: execute deletion immediately - resetDeleteBtn(); - promptStates.delete(state.pid); - await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) }); - renderDOM(); - } - }; - - // --- IN-PLACE SMOOTH FLICKER-FREE RENDERING WITH ASPECT LOCK --- - 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"; - - // If same amount of slots, swap source on matching nodes (prevents image reset collapse) - if (cardObj.grid.children.length === state.images.length) { - state.images.forEach((img, idx) => { - const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; - const thumb = cardObj.grid.children[idx]; - if (thumb.src !== src) { - // Blinking prevention: preload blob images silently and render only on completion - if (src.startsWith("blob:")) { - const tempImg = new Image(); - tempImg.onload = () => { - const oldBlob = thumb._lastBlob; - thumb.src = src; - thumb._lastBlob = src; - if (oldBlob && oldBlob !== src) { - try { URL.revokeObjectURL(oldBlob); } catch(e){} - } - }; - tempImg.src = src; - } else { - thumb.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); - - // Handle in-panel muted auto-playing looping videos elegantly - 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.autoplay = true; - thumb.loop = true; - thumb.muted = true; - thumb.playsInline = true; - } - if (!keepAspect) { thumb.style.aspectRatio = "1 / 1"; thumb.style.objectFit = "cover"; } - thumb.setAttribute("draggable", "false"); - - // Lock aspect ratio on load to stabilize the container during subsequent updates - 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; - } - - // Fullscreen preview click trigger - thumb.onclick = (ev) => { - ev.stopPropagation(); - const srcs = state.images.map(i => i.url ? i.url : `/view?filename=${encodeURIComponent(i.filename)}&type=${i.type}&subfolder=${encodeURIComponent(i.subfolder)}`); - showFullscreenPreview(srcs); - }; - - if (!cardObj.firstImgElement) cardObj.firstImgElement = thumb; - cardObj.grid.appendChild(thumb); - }); - } - } - cardObj.lastImagesSignature = currentImagesSignature; - } - - if (state.images.length === 0) { - // Render formatted text outputs inside card instead of blindly showing "No Outputs" - if (state.texts && state.texts.length > 0) { - cardObj.placeholder.textContent = state.texts.join("\n"); - Object.assign(cardObj.placeholder.style, { - whiteSpace: "pre-wrap", textAlign: "left", fontSize: "10px", opacity: "0.8" - }); - } else { - cardObj.placeholder.textContent = state.progressText || "No Outputs"; - Object.assign(cardObj.placeholder.style, { - whiteSpace: "normal", textAlign: "center", fontSize: "11px", opacity: "0.5" - }); - } - } - - if (state.status === "active") { - if (showWorkingNode) { - cardObj.statusText.style.display = "block"; - if (state.activeNodeName) { - if (state.activeNodeName === "Finishing...") { - cardObj.statusText.textContent = "Finishing..."; - } else { - const percent = state.progress ? ` ${state.progress}%` : ""; - cardObj.statusText.textContent = `[${state.activeNodeName}]${percent}`; - } - } else { - const percent = state.progress ? ` ${state.progress}%` : ""; - cardObj.statusText.textContent = `Sampling...${percent}`; - } - } 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; // Mark as rendered so we skip processing in future frames - } - - return cardObj.element; - }; - - // Delete items no longer present in states - 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; - - // Dedicated Summary/Standalone Clear Cards Rendering Placement - const getPendingSummaryCard = () => { - 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 syncQueue(); - }; - el.appendChild(cancelBtn); - pCard = { element: el, textDiv }; - cardElements.set("pending-summary-card", pCard); - } - pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`; - return pCard.element; - }; - - const getCancelStandaloneBtn = () => { - let cancelStandalone = cardElements.get("pending-cancel-all-standalone"); - if (!cancelStandalone) { - 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 syncQueue(); - }; - cancelStandalone = { element: btn }; - cardElements.set("pending-cancel-all-standalone", cancelStandalone); - } - return cancelStandalone.element; - }; - - // Sequential Render Execution array builder (Always Waterfall Newest-on-Top) - const targetElements = []; - - if (pendingCount > 0) { - if (showPendingSummary) { - targetElements.push(getPendingSummaryCard()); - } else { - targetElements.push(getCancelStandaloneBtn()); - pendingTasks.forEach(st => targetElements.push(syncCardElement(st))); - } - } - activeTasks.forEach(st => targetElements.push(syncCardElement(st))); - completedTasks.forEach(st => targetElements.push(syncCardElement(st))); - - // In-place dynamic DOM reconciliation to fully eliminate flickering and selection errors - targetElements.forEach((el, index) => { - if (cardStack.children[index] !== el) { - cardStack.insertBefore(el, cardStack.children[index] || null); - } - }); - - // Safely prune residual historical nodes from the bottom - while (cardStack.children.length > targetElements.length) { - cardStack.removeChild(cardStack.lastChild); - } - - // Save updated state schema to localStorage to survive tab reloads safely - saveStatesToLocalStorage(); - }); - }; - - const syncQueue = async () => { - try { - const q = await api.getQueue(); - const runningList = q.Running || q.queue_running || []; - const pendingList = q.Pending || q.queue_pending || []; - const pendingIds = new Set(); - - // Map the queue to normalized objects to reliably sort oldest-first sequence orders - const normalizedPending = pendingList.map((p, idx) => { - let pid = null; - let 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 }; - }); - - // Sort queue oldest-first (lowest absolute server sequence number first) - normalizedPending.sort((a, b) => a.seq - b.seq); - - normalizedPending.forEach((item, index) => { - const pid = item.pid; - // Assign relative positions sequentially (oldest always gets #1) - const number = index + 1; - - if (pid) { - pendingIds.add(pid); - if (!promptStates.has(pid)) { - globalOrderCounter++; - promptStates.set(pid, { - pid: pid, status: "pending", images: [], progress: 0, - queueNumber: number, - progressText: `Pending... (#${number})`, timestamp: globalOrderCounter, - workflow: app.graph.serialize() // Snapshot workflow metadata immediately on queue entry - }); - } else { - // Ensure preexisting pending records update their sequence number correctly - const st = promptStates.get(pid); - if (st.status === "pending") { - st.queueNumber = number; - st.progressText = `Pending... (#${number})`; - } - } - } - }); - - // Clear out expired pending items - for (const [pid, state] of promptStates.entries()) { - if (state.status === "pending" && !pendingIds.has(pid)) { - promptStates.delete(pid); - } - } - - // Count logical badge: All pending tasks + (Active generating task exists ? 1 : 0) - const totalActiveCount = pendingIds.size + (runningList.length > 0 ? 1 : 0); - updateSidebarBadge(totalActiveCount); - renderDOM(); - } catch (err) { - console.error("Comfy Sidebar: Failed to sync queue state", err); - } - }; - - api.addEventListener("status", syncQueue); + // 2. Initialize UI Hierarchy + const sidebarContainer = setupSidebarUI(); - api.addEventListener("execution_start", (e) => { - const pid = e.detail.prompt_id; - currentlyActivePromptId = pid; // Global tracking update - - // Snapshot the active canvas workflow immediately so cancelled/failed runs still preserve their workflow structure - const activeWorkspaceWorkflow = app.graph.serialize(); + // 3. Connect API Hooks + setupApiListeners(); + + // 4. Connect Storage and History Sync + await initSessionAndHistory(); - if (promptStates.has(pid)) { - const st = promptStates.get(pid); - st.status = "active"; - st.progressText = "Sampling..."; - st.workflow = activeWorkspaceWorkflow; - st.rendered = false; // Reset rendering lock when transitioning state - st.startTime = Date.now(); - st.duration = null; - } else { - globalOrderCounter++; - promptStates.set(pid, { - pid: pid, status: "active", images: [], progress: 0, - progressText: "Sampling...", timestamp: 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); - renderDOM(); - } - }); - - // Monitors current executing node ID to print it dynamically above progress track - api.addEventListener("executing", (e) => { - const nodeId = e.detail; - const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true; - - if (showWorkingNode && currentlyActivePromptId && promptStates.has(currentlyActivePromptId)) { - const st = promptStates.get(currentlyActivePromptId); - if (nodeId) { - const node = app.graph.getNodeById(nodeId); - st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`; - } else { - st.activeNodeName = "Finishing..."; - } - renderDOM(); - } - }); - - api.addEventListener("b_preview", (e) => { - const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active"); - if (activeTasks.length > 0) { - const st = activeTasks[0]; - - // Revoke old object URLs to save memory - if (st._previewBlobUrl) { - try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){} - } - - st._previewBlobUrl = URL.createObjectURL(e.detail); - st.images = [{ url: st._previewBlobUrl }]; - renderDOM(); - } - }); - - 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; - } - const finalTexts = findTextsInOutputs({ output: e.detail.output }); - promptStates.get(e.detail.prompt_id).texts = finalTexts; - renderDOM(); - } - }); - - const concludeRun = async (pid, statusStr) => { - if (!pid || !promptStates.has(pid)) return; - if (currentlyActivePromptId === pid) { - currentlyActivePromptId = null; // Clear executing state - } - const st = promptStates.get(pid); - st.status = statusStr; - st.progressText = ""; - st.rendered = false; // Reset dynamic lock so we render final outputs exactly once - 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]) { - // Update fallback workflow if it was not captured during execution start - 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(); - syncQueue(); - }; - - 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", (e) => { - const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active"); - activeTasks.forEach(t => concludeRun(t.pid, "cancelled")); - syncQueue(); - }); - - // Toggle Sidebar Tab open/close on keyboard shortcut 'q' or 'Q' key press + // 5. Connect UI Keyboard & Override Hacks document.addEventListener("keydown", (e) => { const activeEl = document.activeElement; - if (activeEl && ( - activeEl.tagName === "INPUT" || - activeEl.tagName === "TEXTAREA" || - activeEl.isContentEditable || - activeEl.tagName === "SELECT" - )) { - return; - } - + if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return; if (e.key.toLowerCase() === "q") { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); e.stopPropagation(); const ourBtn = findOurSidebarButton(); - if (ourBtn) { - ourBtn.click(); - } + if (ourBtn) ourBtn.click(); } - }, true); // Capture phase listener to safely override standard ComfyUI hotkeys + }, true); + + setInterval(() => applySidebarOverride(), 500); - // Periodically check and apply override/positioning to prevent race conditions during page changes - setInterval(() => { - applySidebarOverride(); - }, 500); - - // --- BACKEND SESSION RESTARTS HANDLER --- - 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 backend session endpoint not found. Queue persistence will fall back to cache.", err); - } - - const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id"); - - if (backendSessionId && backendSessionId === storedSessionId) { - // Server did not restart! Safely load cached queue state from localStorage - loadStatesFromLocalStorage(); - } else { - // Server restarted or first run! Wipe old cache and store new session ID - localStorage.removeItem("comfy_sidebar_prompt_states"); - if (backendSessionId) { - localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId); - } - } - - // Initialize history list - const historyData = await api.getHistory(); - const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b)); - ids.forEach(id => { - // Keep specialized cancelled/error session records loaded from localStorage intact - if (promptStates.has(id)) { - return; - } - - const images = findImagesInOutputs(historyData[id].outputs); - const texts = findTextsInOutputs(historyData[id].outputs); - - // Skip loading history items that have absolutely no outputs (images/texts) to prevent blank cards on refresh - if (images.length === 0 && texts.length === 0) { - return; - } - - globalOrderCounter++; - promptStates.set(id, { - pid: id, status: "completed", - images: images, - texts: texts, - workflow: historyData[id].extra_data?.extra_pnginfo?.workflow || null, - progressText: "", timestamp: globalOrderCounter, - rendered: true // Mark history loaded outputs as already rendered - }); + // 6. Final Registration + app.extensionManager.registerSidebarTab({ + id: "classic-comfy-sidebar", + icon: "pi pi-images", + title: "Queue", + tooltip: "Comfy Queue (Q)", + type: "custom", + render: (el) => { el.appendChild(sidebarContainer); } }); - pruneHistory(); - syncQueue(); - - app.extensionManager.registerSidebarTab({ id: "classic-comfy-sidebar", icon: "pi pi-images", title: "Queue", tooltip: "Comfy Queue (Q)", type: "custom", render: (el) => { el.appendChild(sidebarContainer); } }); } }); \ No newline at end of file diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..2dccd18 --- /dev/null +++ b/js/state.js @@ -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); + } +} \ No newline at end of file diff --git a/js/styles.js b/js/styles.js new file mode 100644 index 0000000..948c752 --- /dev/null +++ b/js/styles.js @@ -0,0 +1,27 @@ +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 .pi-times { display: none !important; } + .comfy-sidebar-card.pending:hover .pi-times { 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; + } + `; + document.head.appendChild(style); +} \ No newline at end of file diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..1af4a7e --- /dev/null +++ b/js/ui.js @@ -0,0 +1,440 @@ +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.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); + + const clearHistoryBtn = document.createElement("button"); + clearHistoryBtn.textContent = "Clear History"; + Object.assign(clearHistoryBtn.style, { + background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)", + borderRadius: "3px", padding: "3px 8px", cursor: "pointer", fontSize: "11px", fontWeight: "bold", transition: "all 0.15s ease-in-out" + }); + + let clearHistoryTimeout = null, isClearHistoryPending = false; + const resetClearHistoryBtn = () => { + isClearHistoryPending = false; + clearHistoryBtn.textContent = "Clear History"; + Object.assign(clearHistoryBtn.style, { color: "var(--desc-color, #aaa)", background: "transparent", borderColor: "var(--border-color, #555)", boxShadow: "none" }); + if (clearHistoryTimeout) { clearTimeout(clearHistoryTimeout); clearHistoryTimeout = null; } + }; + + clearHistoryBtn.addEventListener("mouseenter", () => { + if (!isClearHistoryPending) { clearHistoryBtn.style.borderColor = "var(--fg-color, #eee)"; clearHistoryBtn.style.color = "var(--fg-color, #eee)"; } + }); + clearHistoryBtn.addEventListener("mouseleave", () => { + if (!isClearHistoryPending) { clearHistoryBtn.style.borderColor = "var(--border-color, #555)"; clearHistoryBtn.style.color = "var(--desc-color, #aaa)"; } + }); + + clearHistoryBtn.onclick = async (ev) => { + ev.stopPropagation(); + if (!isClearHistoryPending) { + isClearHistoryPending = true; + clearHistoryBtn.textContent = "Confirm?"; + Object.assign(clearHistoryBtn.style, { color: "#fff", background: "#dc3545", borderColor: "#dc3545", boxShadow: "0 0 8px rgba(220, 53, 69, 0.6)" }); + clearHistoryTimeout = setTimeout(resetClearHistoryBtn, 1500); + } else { + resetClearHistoryBtn(); + for (const [pid, state] of promptStates.entries()) { + if (state.status !== "pending" && state.status !== "active") { promptStates.delete(pid); cardElements.delete(pid); } + } + try { await api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clear: true }) }); } catch (err) {} + renderDOM(); + } + }; + standardHeader.appendChild(clearHistoryBtn); + + 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"; + Object.assign(cancelX.style, { position: "absolute", top: "4px", right: "4px", color: "#dc3545", cursor: "pointer", fontSize: "20px", display: "none", zIndex: "10", background: "rgba(0,0,0,0.85)", padding: "8px 12px", borderRadius: "4px", transition: "color 0.2s" }); + 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" }); + 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" }); + 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" }); + 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 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: "0" }); 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" }); 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"; + if (cardObj.grid.children.length === state.images.length) { + state.images.forEach((img, idx) => { + const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type}&subfolder=${encodeURIComponent(img.subfolder)}`; + const thumb = cardObj.grid.children[idx]; + if (thumb.src !== src) { + if (src.startsWith("blob:")) { const tempImg = new Image(); tempImg.onload = () => { const oldBlob = thumb._lastBlob; thumb.src = src; thumb._lastBlob = src; if (oldBlob && oldBlob !== src) try { URL.revokeObjectURL(oldBlob); } catch(e){} }; tempImg.src = src; } + else thumb.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); + 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.autoplay = true; thumb.loop = true; thumb.muted = true; thumb.playsInline = true; } + 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; + } + 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)}`)); }; + if (!cardObj.firstImgElement) cardObj.firstImgElement = thumb; + cardObj.grid.appendChild(thumb); + }); + } + } + 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", fontSize: "10px", opacity: "0.8" }); + } else { + cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center", fontSize: "11px", opacity: "0.5" }); + } + } + + 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(); + }); +} \ No newline at end of file diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..88ff431 --- /dev/null +++ b/js/utils.js @@ -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; +} \ No newline at end of file