diff --git a/js/comparison.js b/js/comparison.js index 8db475a..ecccf1d 100644 --- a/js/comparison.js +++ b/js/comparison.js @@ -1,5 +1,6 @@ import { isVideoFormat, is3DFormat } from "./utils.js"; import { app } from "/scripts/app.js"; +import { State } from "./state.js"; let activeComparisonViewer = null; let globalKeydownHandler = null; @@ -11,6 +12,12 @@ const getClientX = (e) => { return e.clientX || 0; }; +const getClientY = (e) => { + if (e.touches && e.touches.length > 0) return e.touches[0].clientY; + if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientY; + return e.clientY || 0; +}; + const createMediaElement = (src, muted = false) => { const isVideo = isVideoFormat(src); const el = isVideo ? document.createElement("video") : document.createElement("img"); @@ -23,7 +30,8 @@ const createMediaElement = (src, muted = false) => { } Object.assign(el.style, { gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh", - objectFit: "contain", pointerEvents: "none" + width: "auto", height: "auto", objectFit: "contain", + pointerEvents: "none", userSelect: "none", webkitUserSelect: "none" }); el.src = isVideo ? src + "#t=0.001" : src; return el; @@ -144,42 +152,86 @@ function createComparisonViewer(baseSrc) { } const isBaseVideo = isVideoFormat(baseSrc); - const canvasEl = document.querySelector("#graph-canvas, canvas"); - const targetContainer = canvasEl ? canvasEl.parentNode : document.body; - - if (targetContainer && targetContainer !== document.body) { - targetContainer.style.position = "relative"; - } + // Root overlay fixed to the screen const container = document.createElement("div"); container.className = "comfy-sidebar-comparison-overlay"; Object.assign(container.style, { - position: "absolute", top: "0", left: "0", width: "100%", height: "100%", + position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column", - alignItems: "center", justifyContent: "center", - zIndex: "10", + zIndex: "1000", boxSizing: "border-box", overflow: "hidden", pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none" }); + // Dynamic bounds updater: perfectly shifts the overlay outside the active sidebar + const updateOverlayBounds = () => { + const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer; + + if (sidebarEl && sidebarEl.offsetWidth > 0 && sidebarEl.isConnected) { + const rect = sidebarEl.getBoundingClientRect(); + if (rect.left < window.innerWidth / 2) { + const leftOffset = Math.max(0, rect.right); + container.style.left = `${leftOffset}px`; + container.style.width = `calc(100vw - ${leftOffset}px)`; + container.style.right = "0px"; + } else { + const rightOffset = Math.max(0, window.innerWidth - rect.left); + container.style.left = "0px"; + container.style.width = `calc(100vw - ${rightOffset}px)`; + container.style.right = `${rightOffset}px`; + } + container.style.top = `${Math.max(0, rect.top)}px`; + container.style.height = `calc(100vh - ${Math.max(0, rect.top)}px)`; + } else { + container.style.left = "0px"; + container.style.width = "100vw"; + container.style.top = "0px"; + container.style.height = "100vh"; + } + }; + + updateOverlayBounds(); + window.addEventListener("resize", updateOverlayBounds); + + let resizeObserver = null; + const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer; + if (sidebarEl && window.ResizeObserver) { + resizeObserver = new ResizeObserver(() => updateOverlayBounds()); + resizeObserver.observe(sidebarEl); + } + + // Scrollable viewport container: allows 100% zoom navigation without moving fixed controls + const scrollContainer = document.createElement("div"); + Object.assign(scrollContainer.style, { + position: "absolute", top: "0", left: "0", width: "100%", height: "100%", + display: "flex", overflow: "auto", boxSizing: "border-box", + padding: "54px 28px 48px 28px", scrollbarWidth: "thin", + scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15" + }); + container.appendChild(scrollContainer); + const header = document.createElement("div"); Object.assign(header.style, { - position: "absolute", top: "16px", display: "flex", gap: "16px", - zIndex: "30", color: "#aaa", fontSize: "12px", fontFamily: "sans-serif", - pointerEvents: "none" + position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)", + display: "flex", gap: "16px", zIndex: "30", color: "#aaa", fontSize: "12px", + fontFamily: "sans-serif", pointerEvents: "none", background: "rgba(10,10,10,0.75)", + padding: "4px 10px", borderRadius: "4px", backdropFilter: "blur(4px)", + boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center" }); const infoText = document.createElement("span"); infoText.textContent = isBaseVideo ? "Video playback. Press Esc to close." - : "Reference loaded. Click to replace | Shift+Click another card image to compare."; + : "Click image to zoom (100%/Fit) | Shift+Click another card to compare."; header.appendChild(infoText); container.appendChild(header); const closeBtn = document.createElement("span"); closeBtn.className = "pi pi-times"; - closeBtn.title = "Close Comparison (Esc)"; + closeBtn.title = "Close (Esc)"; Object.assign(closeBtn.style, { position: "absolute", top: "16px", right: "24px", zIndex: "30", - cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease" + cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease", + background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px" }); closeBtn.onmouseenter = () => closeBtn.style.color = "#fff"; closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa"; @@ -188,15 +240,17 @@ function createComparisonViewer(baseSrc) { const wrapper = document.createElement("div"); Object.assign(wrapper.style, { position: "relative", display: "grid", placeItems: "center", - maxWidth: "85%", maxHeight: "85%" + maxWidth: "85%", maxHeight: "85%", margin: "auto", + flexShrink: "0", cursor: isBaseVideo ? "default" : "zoom-in" }); - container.appendChild(wrapper); + scrollContainer.appendChild(wrapper); const hintPrompt = document.createElement("div"); Object.assign(hintPrompt.style, { - position: "absolute", bottom: "16px", zIndex: "30", - color: "#888", fontSize: "12px", fontFamily: "sans-serif", - pointerEvents: "none" + position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)", + zIndex: "30", color: "#888", fontSize: "12px", fontFamily: "sans-serif", + pointerEvents: "none", background: "rgba(10,10,10,0.75)", padding: "4px 10px", + borderRadius: "4px", backdropFilter: "blur(4px)", boxShadow: "0 2px 6px rgba(0,0,0,0.4)" }); hintPrompt.innerHTML = 'Tip: Hold Shift while clicking sidebar cards to compare outputs side-by-side.'; if (isBaseVideo) { @@ -204,22 +258,11 @@ function createComparisonViewer(baseSrc) { } container.appendChild(hintPrompt); - let wasDragging = false; - - container.onclick = (e) => { - if (e.target === container) { - if (wasDragging) { - wasDragging = false; - return; - } - destroy(); - } - }; - let mediaA = createMediaElement(baseSrc, false); wrapper.appendChild(mediaA); let mediaB = null; + let isZoomed = false; const slider = document.createElement("div"); Object.assign(slider.style, { @@ -242,7 +285,6 @@ function createComparisonViewer(baseSrc) { wrapper.appendChild(slider); let splitRatio = 50; - let isDragging = false; let destroyVideoPlaybackFn = null; const updateSliderPosition = (percent) => { @@ -253,27 +295,161 @@ function createComparisonViewer(baseSrc) { } }; + // Synchronize dimensions, aspect ratios, and upscaler scaling + const syncImageScales = () => { + if (!mediaA) return; + + const wA = mediaA.naturalWidth || mediaA.videoWidth || 0; + const hA = mediaA.naturalHeight || mediaA.videoHeight || 0; + + if (!mediaB) { + // Single image mode + if (isZoomed && wA && hA) { + wrapper.style.maxWidth = "none"; + wrapper.style.maxHeight = "none"; + wrapper.style.width = `${wA}px`; + wrapper.style.height = `${hA}px`; + wrapper.style.cursor = "zoom-out"; + + mediaA.style.maxWidth = "none"; + mediaA.style.maxHeight = "none"; + mediaA.style.width = "100%"; + mediaA.style.height = "100%"; + mediaA.style.objectFit = "fill"; + } else { + wrapper.style.maxWidth = "85%"; + wrapper.style.maxHeight = "85%"; + wrapper.style.width = "auto"; + wrapper.style.height = "auto"; + wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in"; + + mediaA.style.maxWidth = "100%"; + mediaA.style.maxHeight = "80vh"; + mediaA.style.width = "auto"; + mediaA.style.height = "auto"; + mediaA.style.objectFit = "contain"; + } + return; + } + + const wB = mediaB.naturalWidth || mediaB.videoWidth || 0; + const hB = mediaB.naturalHeight || mediaB.videoHeight || 0; + + if (!wA || !hA || !wB || !hB) return; + + // Aspect ratio verification (5% tolerance) + const arA = wA / hA; + const arB = wB / hB; + const arDiff = Math.abs(arA - arB) / Math.max(arA, arB); + + if (arDiff > 0.05) { + infoText.textContent = `Aspect ratio mismatch (${wA}x${hA} vs ${wB}x${hB}). Images must have matching proportions to compare.`; + infoText.style.color = "#f87171"; + setTimeout(() => { + if (infoText) { + infoText.style.color = "#aaa"; + infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare."; + } + }, 3500); + + cleanupMedia(mediaB); + mediaB.remove(); + mediaB = null; + slider.style.display = "none"; + syncImageScales(); + return; + } + + // Proportions match: stretch to the larger image size for perfect upscaler comparison + const maxW = Math.max(wA, wB); + const maxH = Math.max(hA, hB); + + if (isZoomed) { + wrapper.style.maxWidth = "none"; + wrapper.style.maxHeight = "none"; + wrapper.style.width = `${maxW}px`; + wrapper.style.height = `${maxH}px`; + wrapper.style.cursor = "zoom-out"; + + [mediaA, mediaB].forEach(el => { + if (el) { + el.style.maxWidth = "none"; + el.style.maxHeight = "none"; + el.style.width = "100%"; + el.style.height = "100%"; + el.style.objectFit = "fill"; + } + }); + } else { + wrapper.style.maxWidth = "85%"; + wrapper.style.maxHeight = "85%"; + wrapper.style.width = "auto"; + wrapper.style.height = "auto"; + wrapper.style.aspectRatio = `${maxW} / ${maxH}`; + wrapper.style.cursor = "zoom-in"; + + [mediaA, mediaB].forEach(el => { + if (el) { + el.style.maxWidth = "100%"; + el.style.maxHeight = "80vh"; + el.style.width = "100%"; + el.style.height = "100%"; + el.style.objectFit = "fill"; + el.style.aspectRatio = `${maxW} / ${maxH}`; + } + }); + } + }; + + mediaA.onload = syncImageScales; + if (mediaA.complete) syncImageScales(); + + const toggleZoom = () => { + if (isBaseVideo) return; + isZoomed = !isZoomed; + syncImageScales(); + if (!isZoomed) { + scrollContainer.scrollTop = 0; + scrollContainer.scrollLeft = 0; + } + }; + + // Clean drag & click arbitration + let isDraggingSlider = false; + let dragMoved = false; + let dragStartX = 0; + let dragStartY = 0; + const startDrag = (e) => { - e.preventDefault(); - e.stopPropagation(); - isDragging = true; - wasDragging = false; - - const rect = wrapper.getBoundingClientRect(); - const clientX = getClientX(e); - const percent = ((clientX - rect.left) / rect.width) * 100; - updateSliderPosition(percent); + if (e.button && e.button !== 0) return; + dragStartX = getClientX(e); + dragStartY = getClientY(e); + dragMoved = false; + isDraggingSlider = true; }; + const doDrag = (e) => { - if (!isDragging) return; - wasDragging = true; - const rect = wrapper.getBoundingClientRect(); + if (!isDraggingSlider) return; const clientX = getClientX(e); - const percent = ((clientX - rect.left) / rect.width) * 100; - updateSliderPosition(percent); + const clientY = getClientY(e); + + if (!dragMoved && (Math.abs(clientX - dragStartX) > 4 || Math.abs(clientY - dragStartY) > 4)) { + dragMoved = true; + } + + if (dragMoved && mediaB) { + const rect = wrapper.getBoundingClientRect(); + const percent = ((clientX - rect.left) / rect.width) * 100; + updateSliderPosition(percent); + } }; + const endDrag = () => { - isDragging = false; + if (!isDraggingSlider) return; + isDraggingSlider = false; + if (dragMoved) { + setTimeout(() => { dragMoved = false; }, 50); + } }; wrapper.addEventListener("mousedown", startDrag); @@ -284,6 +460,20 @@ function createComparisonViewer(baseSrc) { window.addEventListener("mouseup", endDrag); window.addEventListener("touchend", endDrag); + // Clicking image area toggles zoom + wrapper.addEventListener("click", (e) => { + if (dragMoved || isBaseVideo) return; + toggleZoom(); + }); + + // Clicking backdrop cleanly closes in 1 click + scrollContainer.addEventListener("click", (e) => { + if (dragMoved) return; + if (e.target === scrollContainer || e.target === container) { + destroy(); + } + }); + const cleanupMedia = (el) => { if (el && el.tagName === "VIDEO") { el.pause(); @@ -293,6 +483,12 @@ function createComparisonViewer(baseSrc) { }; const destroy = () => { + window.removeEventListener("resize", updateOverlayBounds); + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + window.removeEventListener("mousemove", doDrag); window.removeEventListener("touchmove", doDrag); window.removeEventListener("mouseup", endDrag); @@ -325,7 +521,7 @@ function createComparisonViewer(baseSrc) { globalKeydownHandler = handleKeys; document.addEventListener("keydown", globalKeydownHandler); - targetContainer.appendChild(container); + document.body.appendChild(container); if (isBaseVideo) { destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container); @@ -335,16 +531,12 @@ function createComparisonViewer(baseSrc) { loadTarget(targetSrc, isShiftClick) { const isTargetVideo = isVideoFormat(targetSrc); - if (isBaseVideo) { + if (isBaseVideo || isTargetVideo) { destroy(); showFullscreenPreview([targetSrc], isShiftClick); return; } - if (isTargetVideo) { - return; - } - if (isShiftClick) { if (mediaB) { cleanupMedia(mediaB); @@ -353,11 +545,17 @@ function createComparisonViewer(baseSrc) { mediaB = createMediaElement(targetSrc, false); mediaB.style.pointerEvents = "none"; + mediaB.onload = () => { + syncImageScales(); + slider.style.display = "block"; + infoText.style.color = "#aaa"; + infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card | Esc to close."; + updateSliderPosition(50); + }; wrapper.appendChild(mediaB); - - slider.style.display = "block"; - infoText.textContent = "Drag the slider to compare. Shift+Click other card images to update target | Esc to close."; - updateSliderPosition(50); + if (mediaB.complete && mediaB.naturalWidth) { + mediaB.onload(); + } } else { if (mediaB) { cleanupMedia(mediaB); @@ -366,7 +564,11 @@ function createComparisonViewer(baseSrc) { } slider.style.display = "none"; mediaA.src = targetSrc; - infoText.textContent = "Reference loaded. Click to replace | Shift+Click another card image to compare."; + mediaA.onload = () => { + infoText.style.color = "#aaa"; + infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare."; + syncImageScales(); + }; } } }; diff --git a/js/ui.js b/js/ui.js index 7c36460..2b76c35 100644 --- a/js/ui.js +++ b/js/ui.js @@ -593,7 +593,7 @@ function renderCardImages(cardObj, state, keepAspect) { } }; - const isUnfinished = state.status && state.status !== "completed"; + const isUnfinished = state.status ? (state.status !== "completed") : false; if (isUnfinished) { mediaEl.setAttribute("draggable", "false"); mediaEl.style.cursor = "grab"; @@ -615,6 +615,9 @@ function renderCardImages(cardObj, state, keepAspect) { e.dataTransfer.setData("text/uri-list", fullSrc); e.dataTransfer.setData("text/plain", fullSrc); e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`); + if (state && state.workflow) { + e.dataTransfer.setData("application/json", JSON.stringify(state.workflow)); + } } catch (err) {} e.dataTransfer.effectAllowed = "copy"; e.stopPropagation(); @@ -894,7 +897,7 @@ export function renderDOM() { cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, { pid: batchInfo.pid, images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs }, keepAspect); + renderCardImages(cardObj, { pid: batchInfo.pid, status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs }, keepAspect); targetElements.push(cardObj.element); }); @@ -1005,7 +1008,7 @@ export function renderDOM() { if (out.images && out.images.length > 0) { cardObj.placeholder.style.display = "none"; - renderCardImages(cardObj, { pid: st.pid, images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs }, keepAspect); + renderCardImages(cardObj, { pid: st.pid, status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs }, keepAspect); } else { cardObj.placeholder.style.display = "block"; cardObj.placeholder.textContent = "No Outputs"; @@ -1200,7 +1203,7 @@ export function renderDOM() { } }; - const currentImagesSignature = state.images ? state.images.map(img => img.url || img.filename).join("|") : ""; + const currentImagesSignature = `${state.status || ""}:${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";