import { app } from "../../scripts/app.js"; // CONSTANTS const GLOBAL_HIDE_SETTING_ID = "SATA_UtilityNode.GlobalPreviewHide"; const PREVIEW_MACHINE_CLASS = "Preview_Machine"; const SAVE_MACHINE_CLASS = "Save_Machine"; // Track hover state const hoveredNodeIds = new Set(); // Inject stylesheet for Nodes 2.0 (Vue DOM) and Legacy V1 DOM preview masking function injectHiderCSS() { if (document.getElementById("sata-preview-hider-styles")) return; const style = document.createElement("style"); style.id = "sata-preview-hider-styles"; style.textContent = ` /* Mask only the image container itself in-place */ .sata-image-masked { position: relative !important; min-height: 100px !important; background-color: #1a1a1a !important; border-radius: 6px !important; border: 1px dashed #444 !important; box-sizing: border-box !important; overflow: hidden !important; transition: background-color 0.15s ease; } /* Hide all img/canvas/video children inside the masked image container */ .sata-image-masked img, .sata-image-masked canvas, .sata-image-masked video { opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; transition: opacity 0.15s ease; } /* Center placeholder text cleanly over the masked image container */ .sata-image-masked::after { content: "Hidden (Hover to Reveal)" !important; position: absolute !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; color: #888 !important; font-size: 13px !important; font-weight: bold !important; font-family: sans-serif !important; text-align: center !important; white-space: nowrap !important; pointer-events: none !important; opacity: 1 !important; visibility: visible !important; z-index: 10 !important; } /* NATIVE CSS HOVER REVEAL: Works across Nodes 2.0 Vue Nodes & Legacy LiteGraph DOM */ .sata-image-masked:hover img, .sata-image-masked:hover canvas, .sata-image-masked:hover video, [data-node-id]:hover .sata-image-masked img, [data-node-id]:hover .sata-image-masked canvas, [data-node-id]:hover .sata-image-masked video, .comfy-vue-node:hover .sata-image-masked img, .comfy-vue-node:hover .sata-image-masked canvas, .comfy-vue-node:hover .sata-image-masked video, .node:hover .sata-image-masked img, .node:hover .sata-image-masked canvas, .node:hover .sata-image-masked video, .lgraphnode:hover .sata-image-masked img, .lgraphnode:hover .sata-image-masked canvas, .lgraphnode:hover .sata-image-masked video { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; } .sata-image-masked:hover::after, [data-node-id]:hover .sata-image-masked::after, .comfy-vue-node:hover .sata-image-masked::after, .node:hover .sata-image-masked::after, .lgraphnode:hover .sata-image-masked::after { display: none !important; opacity: 0 !important; visibility: hidden !important; } .sata-image-masked:hover, [data-node-id]:hover .sata-image-masked, .comfy-vue-node:hover .sata-image-masked, .node:hover .sata-image-masked, .lgraphnode:hover .sata-image-masked { background-color: transparent !important; border: none !important; } `; document.head.appendChild(style); } // Helper to get settings for both ComfyUI V1 and V2 function getSettingValue(id, defaultValue) { if (app.extensionManager && app.extensionManager.setting) { const val = app.extensionManager.setting.get(id); return val !== undefined ? val : defaultValue; } else if (app.ui && app.ui.settings) { return app.ui.settings.getSettingValue(id, defaultValue); } return defaultValue; } app.registerExtension({ name: "SATA_UtilityNode.Preview_Machine", settings: [ { id: GLOBAL_HIDE_SETTING_ID, name: "SATA Utility: Global Hide Previews", type: "boolean", defaultValue: false, onChange: (value) => { if (app.graph) app.graph.setDirtyCanvas(true, true); } } ], async setup() { injectHiderCSS(); setupGlobalMouseTracker(); }, async nodeCreated(node) { if (isTargetNode(node)) { setupNodeHider(node); } }, async loadedGraphNode(node) { if (isTargetNode(node)) { setupNodeHider(node); } } }); function isTargetNode(node) { if (!node || !node.comfyClass) return false; return node.comfyClass === "PreviewImage" || node.comfyClass === "SaveImage" || node.comfyClass === PREVIEW_MACHINE_CLASS || node.comfyClass === SAVE_MACHINE_CLASS; } function shouldHideNode(node) { const globalHide = getSettingValue(GLOBAL_HIDE_SETTING_ID, false); if (globalHide) return true; const hideWidget = node.widgets?.find(w => w.name === "hide_preview"); if (hideWidget) { return !!hideWidget.value; } if (node.comfyClass === PREVIEW_MACHINE_CLASS || node.comfyClass === SAVE_MACHINE_CLASS) { return true; } return false; } function isMouseOverNode(node) { if (!node || !node.pos || !node.size) return false; if (app.canvas && app.canvas.node_over === node) return true; if (!app.canvas || !app.canvas.graph_mouse) return false; const mouse = app.canvas.graph_mouse; const x = mouse[0]; const y = mouse[1]; return x >= node.pos[0] && x <= (node.pos[0] + node.size[0]) && y >= node.pos[1] && y <= (node.pos[1] + node.size[1]); } function isNodeHovered(node) { if (!node) return false; if (app.canvas && app.canvas.node_over === node) return true; if (hoveredNodeIds.has(node.id)) return true; return isMouseOverNode(node); } function shouldHideNow(node) { const shouldHide = shouldHideNode(node); const hovered = isNodeHovered(node); return shouldHide && !hovered; } function getNodeDOMElement(node) { if (!node) return null; if (node.DOMelement) return node.DOMelement; if (node.element) return node.element; const id = String(node.id); return document.querySelector(`[data-node-id="${id}"]`) || document.querySelector(`[data-id="${id}"]`) || document.getElementById(`node-${id}`) || document.querySelector(`.node-${id}`) || document.querySelector(`.comfy-vue-node[data-id="${id}"]`); } function hasImages(node) { if (!node) return false; if (node.imgs && node.imgs.length > 0) return true; const el = getNodeDOMElement(node); if (el) { const img = el.querySelector("img, canvas.comfy-img-preview, .comfy-img-preview, .node-image-preview, .comfy-vue-image-preview"); if (img) return true; } if (node.widgets) { for (const w of node.widgets) { if (w.type === "image" || w.name === "preview" || w.imageEl || w.element?.querySelector?.("img")) { return true; } } } return false; } function getImageContainers(node) { const containers = new Set(); const el = getNodeDOMElement(node); if (el) { // Find existing image preview containers inside the node DOM const previewEls = el.querySelectorAll( ".comfy-img-preview, .node-image-preview, .comfy-vue-image-preview, .node-widget-image, [class*='ImagePreview'], [class*='image-preview'], [class*='node-image']" ); previewEls.forEach(p => containers.add(p)); // Direct img elements parent wrappers const imgs = el.querySelectorAll("img, canvas.comfy-img-preview"); imgs.forEach(img => { if (img.parentElement && img.parentElement !== el) { containers.add(img.parentElement); } else { containers.add(img); } }); } if (node.widgets) { for (const widget of node.widgets) { if (widget.element && (widget.type === "image" || widget.name === "preview" || widget.element.querySelector("img"))) { containers.add(widget.element); } if (widget.imageEl) { containers.add(widget.imageEl.parentElement || widget.imageEl); } } } return Array.from(containers); } function setupGlobalMouseTracker() { let rafPending = false; const checkAndUpdate = () => { if (!app.graph || !app.graph._nodes) return; let changed = false; for (const node of app.graph._nodes) { if (!isTargetNode(node)) continue; const currentlyHovered = isMouseOverNode(node); const wasHovered = hoveredNodeIds.has(node.id); if (currentlyHovered !== wasHovered) { if (currentlyHovered) { hoveredNodeIds.add(node.id); } else { hoveredNodeIds.delete(node.id); } changed = true; } syncNodeDOMVisibility(node); } if (changed && app.graph) { app.graph.setDirtyCanvas(true, true); } }; window.addEventListener("mousemove", () => { if (!rafPending) { rafPending = true; requestAnimationFrame(() => { rafPending = false; checkAndUpdate(); }); } }); window.addEventListener("mouseout", (e) => { if (e.relatedTarget === null) { hoveredNodeIds.clear(); if (app.graph) app.graph.setDirtyCanvas(true, true); } }); } function syncNodeDOMVisibility(node) { if (!node) return; const el = getNodeDOMElement(node); if (el && !el._sata_mouse_bound) { el._sata_mouse_bound = true; el.addEventListener("mouseenter", () => { hoveredNodeIds.add(node.id); syncNodeDOMVisibility(node); if (app.graph) app.graph.setDirtyCanvas(true, true); }); el.addEventListener("mouseleave", () => { hoveredNodeIds.delete(node.id); syncNodeDOMVisibility(node); if (app.graph) app.graph.setDirtyCanvas(true, true); }); } // Apply DOM masking when images exist AND hide_preview is active const nodeHasImages = hasImages(node); const hide = nodeHasImages && shouldHideNode(node); const containers = getImageContainers(node); containers.forEach(container => { if (hide) { container.classList.add("sata-image-masked"); } else { container.classList.remove("sata-image-masked"); } }); } function setupNodeHider(node) { if (node._sata_hider_v12) return; node._sata_hider_v12 = true; injectHiderCSS(); const patchWidgets = () => { if (!node.widgets) return; for (const widget of node.widgets) { if (widget._sata_patched) continue; if (widget.type === "image" || widget.name === "preview") { if (widget.draw && typeof widget.draw === 'function') { const origDraw = widget.draw.bind(widget); widget.draw = function (ctx, nodeRef, widgetWidth, y, widgetHeight) { const targetNode = nodeRef || node; if (hasImages(targetNode) && shouldHideNow(targetNode)) { ctx.save(); ctx.fillStyle = "#1a1a1a"; ctx.fillRect(0, y, widgetWidth, widgetHeight); ctx.restore(); return; } return origDraw(ctx, targetNode, widgetWidth, y, widgetHeight); }; widget._sata_patched = true; } if (widget.drawWidget && typeof widget.drawWidget === 'function') { const origDrawWidget = widget.drawWidget.bind(widget); widget.drawWidget = function (...args) { if (hasImages(node) && shouldHideNow(node)) { return; } return origDrawWidget(...args); }; widget._sata_patched = true; } } } syncNodeDOMVisibility(node); }; patchWidgets(); const origOnDrawBackground = node.onDrawBackground; node.onDrawBackground = function (ctx) { patchWidgets(); syncNodeDOMVisibility(node); if (origOnDrawBackground) { origOnDrawBackground.apply(this, arguments); } }; const origOnDrawForeground = node.onDrawForeground; node.onDrawForeground = function (ctx) { if (origOnDrawForeground) { origOnDrawForeground.apply(this, arguments); } syncNodeDOMVisibility(this); if (hasImages(this) && shouldHideNow(this)) { // Find where settings widgets end so we only mask the image area below widgets let startY = 30; if (this.widgets && this.widgets.length > 0) { const lastWidget = this.widgets[this.widgets.length - 1]; if (lastWidget.last_y !== undefined) { const h = (lastWidget.computeSize ? lastWidget.computeSize(this.size[0])[1] : 20) || 20; startY = lastWidget.last_y + h + 6; } else { startY = 30 + this.widgets.length * 24; } } const maskHeight = Math.max(0, this.size[1] - startY); if (maskHeight > 10) { ctx.save(); ctx.fillStyle = "#1a1a1a"; ctx.fillRect(4, startY, this.size[0] - 8, maskHeight - 4); // Fallback for pure LiteGraph 2D Canvas rendering (when no DOM element exists) const el = getNodeDOMElement(this); if (!el || !el.querySelector(".sata-image-masked")) { ctx.fillStyle = "#888"; ctx.font = "bold 13px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Hidden", this.size[0] / 2, startY + maskHeight / 2 - 8); ctx.font = "10px Arial"; ctx.fillStyle = "#666"; ctx.fillText("(Hover to Reveal)", this.size[0] / 2, startY + maskHeight / 2 + 10); } ctx.restore(); } } }; const origOnExecuted = node.onExecuted; node.onExecuted = function (message) { if (origOnExecuted) { origOnExecuted.apply(this, arguments); } patchWidgets(); syncNodeDOMVisibility(node); setTimeout(() => syncNodeDOMVisibility(node), 20); setTimeout(() => syncNodeDOMVisibility(node), 100); setTimeout(() => syncNodeDOMVisibility(node), 300); if (app.graph) app.graph.setDirtyCanvas(true, true); }; const hideWidget = node.widgets?.find(w => w.name === "hide_preview"); if (hideWidget) { const origCallback = hideWidget.callback; hideWidget.callback = function (value) { if (origCallback) origCallback.call(this, value); syncNodeDOMVisibility(node); if (app.graph) app.graph.setDirtyCanvas(true, true); }; } }