import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js"; import { create3DViewer } from "./viewer3d.js"; import { createTextReader } from "./text_reader.js"; import { stopAllAudioPlayback } from "../ui/audioController.js"; import { SidebarOverlay } from "./overlay.js"; import { store } from "../core/store.js"; import { PromptStatus } from "../core/constants.js"; import { t } from "../core/i18n.js"; let activeComparisonViewer = null; let isRuntimePreviewActive = false; let onRuntimePreviewStateChange = null; export function isRuntimePreviewEnabled() { return isRuntimePreviewActive; } export function getActiveComparisonViewer() { return activeComparisonViewer; } export function setRuntimePreviewStateListener(fn) { onRuntimePreviewStateChange = fn; } export function setRuntimePreviewEnabled(enabled, triggerFocus = true) { isRuntimePreviewActive = !!enabled; if (typeof onRuntimePreviewStateChange === "function") { try { onRuntimePreviewStateChange(isRuntimePreviewActive); } catch (e) {} } if (isRuntimePreviewActive && triggerFocus) { focusRuntimePreviewTarget(); } } function isLatestOrActiveTask(pid) { if (!pid) return false; try { const targetPid = String(pid); const activePid = store.ui.currentlyActivePromptId; // If a job is actively generating in the background: // ONLY that active job is considered active. Any other card is an older card! if (activePid) { return String(activePid) === targetPid; } // If the queue is idle, only the newest prompt in history is the latest const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); if (all.length > 0 && String(all[0].pid) === targetPid) { return true; } } catch (e) {} return false; } export function focusRuntimePreviewTarget() { try { const activePid = store.ui.currentlyActivePromptId; const activePrompt = activePid ? store.getPrompt(activePid) : null; // 1. If a prompt is actively running, focus it (even if it has no image yet) if (activePrompt && activePrompt.status === PromptStatus.ACTIVE) { const previewSrc = activePrompt._previewBlobUrl || (activePrompt.images && activePrompt.images.length > 0 ? (activePrompt.images[0].url || activePrompt.images[0].filename) : null); showFullscreenPreview([previewSrc || ""], false, activePrompt.pid); return; } // 2. Otherwise focus the latest task in history (even if cancelled or text-only) const all = store.getAllPrompts().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); const latest = all[0]; if (latest) { const firstImg = latest.images && latest.images.length > 0 ? latest.images[0] : null; const randParam = latest.pid ? `&rand=${encodeURIComponent(latest.pid)}` : ""; const src = latest._previewBlobUrl || (firstImg ? (firstImg.url || `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}${randParam}`) : ""); showFullscreenPreview([src || ""], false, latest.pid); } else { // Queue is totally empty showFullscreenPreview([""], false, null); } } catch (e) { console.error("Comfy Sidebar: Error focusing runtime preview target:", e); } } export function isAudioViewerOpen() { return !!(activeComparisonViewer && activeComparisonViewer.isAudio); } export function updateActiveComparisonPreview(pid, newSrc) { if (!activeComparisonViewer) return; const targetPidStr = String(pid); // If Runtime Preview is OFF: NEVER interrupt the user while inspecting older cards! if (!isRuntimePreviewActive) { if (activeComparisonViewer.targetPid === targetPidStr) { activeComparisonViewer.loadTarget(newSrc); } return; } // If Runtime Preview is ON: always track the active generation activeComparisonViewer.targetPid = targetPidStr; activeComparisonViewer.loadTarget(newSrc); } const getClientX = (e) => { if (e.touches && e.touches.length > 0) return e.touches[0].clientX; if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientX; return e.clientX || 0; }; const 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; }; function createAudioViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) { let togglePlayFn = () => {}; const overlay = new SidebarOverlay({ onDestroy, onUserClose: () => { if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false); }, onKeyDown: (e) => { if (e.key === " ") { e.preventDefault(); togglePlayFn(); } } }); const playerBox = document.createElement("div"); Object.assign(playerBox.style, { background: "linear-gradient(135deg, #1e1333 0%, #0f172a 100%)", border: "1px solid rgba(255,255,255,0.15)", borderRadius: "12px", padding: "24px 28px", width: "90%", maxWidth: "520px", boxShadow: "0 8px 32px rgba(0,0,0,0.7)", display: "flex", flexDirection: "column", gap: "16px", zIndex: "20", pointerEvents: "auto" }); const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav"; const parsedExt = (parsedFilename.split(".").pop() || "AUDIO").toUpperCase(); const titleRow = document.createElement("div"); Object.assign(titleRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between" }); const title = document.createElement("span"); title.textContent = parsedFilename; Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f1f5f9", fontFamily: "monospace", maxWidth: "75%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }); const badge = document.createElement("span"); badge.className = "comfy-sidebar-audio-badge"; badge.style.position = "static"; badge.textContent = parsedExt; titleRow.append(title, badge); const soundwave = document.createElement("div"); soundwave.className = "comfy-sidebar-soundwave-container"; soundwave.style.height = "56px"; const barHeights = [14, 22, 38, 28, 48, 36, 20, 42, 54, 32, 18, 40, 50, 30, 22, 36, 48, 24, 18, 34, 46, 26, 16]; barHeights.forEach((h, i) => { const bar = document.createElement("div"); bar.className = "comfy-sidebar-soundwave-bar"; bar.style.height = `${h}px`; bar.style.width = "4px"; bar.style.animationDelay = `${(i * 0.06).toFixed(2)}s`; soundwave.appendChild(bar); }); const scrubberContainer = document.createElement("div"); Object.assign(scrubberContainer.style, { width: "100%", height: "6px", background: "#334155", borderRadius: "3px", position: "relative", cursor: "pointer" }); const scrubberFill = document.createElement("div"); Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "3px" }); scrubberContainer.appendChild(scrubberFill); const controlsRow = document.createElement("div"); Object.assign(controlsRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }); const leftControls = document.createElement("div"); Object.assign(leftControls.style, { display: "flex", alignItems: "center", gap: "10px" }); const playIconSvg = ``; const stopIconSvg = ``; const playBtn = document.createElement("button"); Object.assign(playBtn.style, { width: "36px", height: "36px", borderRadius: "50%", background: "#a855f7", border: "none", color: "#fff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "14px" }); playBtn.innerHTML = playIconSvg; const timeLabel = document.createElement("span"); Object.assign(timeLabel.style, { fontSize: "11px", fontFamily: "monospace", color: "#94a3b8" }); timeLabel.textContent = "0:00 / 0:00"; leftControls.append(playBtn, timeLabel); const rightControls = document.createElement("div"); Object.assign(rightControls.style, { display: "flex", alignItems: "center", gap: "8px" }); const loopBtn = document.createElement("button"); Object.assign(loopBtn.style, { background: "transparent", border: "1px solid #444", color: "#aaa", borderRadius: "4px", padding: "4px 8px", fontSize: "11px", cursor: "pointer" }); loopBtn.innerHTML = ``; loopBtn.title = t("Toggle Loop"); const speedSelect = document.createElement("select"); Object.assign(speedSelect.style, { background: "#1e293b", border: "1px solid #444", color: "#eee", borderRadius: "4px", padding: "2px 4px", fontSize: "11px", outline: "none" }); ["0.5x", "1.0x", "1.5x", "2.0x"].forEach(spd => { const opt = document.createElement("option"); opt.value = spd.replace("x", ""); opt.textContent = spd; if (spd === "1.0x") opt.selected = true; speedSelect.appendChild(opt); }); rightControls.append(loopBtn, speedSelect); controlsRow.append(leftControls, rightControls); playerBox.append(titleRow, soundwave, scrubberContainer, controlsRow); overlay.container.appendChild(playerBox); const audioEl = document.createElement("audio"); audioEl.src = baseSrc; audioEl.preload = "metadata"; overlay.container.appendChild(audioEl); const formatTime = (t) => { const m = Math.floor(t / 60); const s = Math.floor(t % 60); return `${m}:${s < 10 ? '0' : ''}${s}`; }; audioEl.onloadedmetadata = () => { timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; }; audioEl.ontimeupdate = () => { if (audioEl.duration > 0) { const percent = (audioEl.currentTime / audioEl.duration) * 100; scrubberFill.style.width = `${percent}%`; timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`; } }; audioEl.onended = () => { playBtn.innerHTML = playIconSvg; playerBox.classList.remove("playing"); scrubberFill.style.width = "0%"; }; togglePlayFn = () => { if (audioEl.paused) { stopAllAudioPlayback(); audioEl.play().catch(()=>{}); playBtn.innerHTML = stopIconSvg; playerBox.classList.add("playing"); } else { audioEl.pause(); audioEl.currentTime = 0; playBtn.innerHTML = playIconSvg; playerBox.classList.remove("playing"); scrubberFill.style.width = "0%"; timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`; } }; playBtn.onclick = togglePlayFn; loopBtn.onclick = () => { audioEl.loop = !audioEl.loop; loopBtn.style.borderColor = audioEl.loop ? "#a855f7" : "#444"; loopBtn.style.color = audioEl.loop ? "#c084fc" : "#aaa"; }; speedSelect.onchange = (e) => { audioEl.playbackRate = Number(e.target.value); }; scrubberContainer.onclick = (e) => { const rect = scrubberContainer.getBoundingClientRect(); const pos = (e.clientX - rect.left) / rect.width; if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration; }; overlay.addCleanup(() => { audioEl.pause(); audioEl.src = ""; }); audioEl.play().then(() => { playBtn.innerHTML = stopIconSvg; playerBox.classList.add("playing"); }).catch(()=>{}); return { isAudio: true, loadTarget(targetSrc) { if (!isAudioFormat(targetSrc)) { overlay.destroy(); onSwitchMedia(targetSrc); return; } const newFilename = getFilenameFromUrl(targetSrc) || "audio.wav"; title.textContent = newFilename; badge.textContent = (newFilename.split(".").pop() || "AUDIO").toUpperCase(); audioEl.src = targetSrc; audioEl.play().then(() => { playBtn.innerHTML = stopIconSvg; playerBox.classList.add("playing"); }).catch(()=>{}); }, destroy: () => overlay.destroy() }; } const createMediaElement = (src, muted = false) => { const isVideo = isVideoFormat(src); const el = isVideo ? document.createElement("video") : document.createElement("img"); el.className = "comfy-sidebar-comparison-media"; if (isVideo) { el.muted = muted; el.playsInline = true; el.autoplay = true; el.loop = true; el.controls = false; } Object.assign(el.style, { gridArea: "1 / 1", maxWidth: "calc(100vw - 120px)", maxHeight: "calc(100vh - 120px)", width: "auto", height: "auto", objectFit: "contain", pointerEvents: isVideo ? "none" : "auto", userSelect: "none", webkitUserSelect: "none", display: "block", border: "none", outline: "none", boxShadow: "none" }); el.draggable = false; el.ondragstart = (e) => e.preventDefault(); el.src = isVideo ? src + "#t=0.001" : src; return el; }; const setupVideoPlayback = (vid, container) => { if (!vid) return () => {}; let videoSyncActive = true; let syncAnimationFrameId = null; const onMetaLoaded = () => { vid.play().catch(()=>{}); }; vid.addEventListener("loadedmetadata", onMetaLoaded); if (vid.readyState >= 1) onMetaLoaded(); const controlBar = document.createElement("div"); Object.assign(controlBar.style, { position: "absolute", bottom: "16px", left: "50%", display: "flex", alignItems: "center", gap: "12px", background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)", border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))", padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px", fontFamily: "monospace", color: "var(--foreground, var(--fg-color, #eee))", width: "80%", maxWidth: "500px", boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)", pointerEvents: "auto" }); const playBtn = document.createElement("span"); playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause"; playBtn.style.cursor = "pointer"; playBtn.style.fontSize = "14px"; playBtn.onclick = (e) => { e.stopPropagation(); if (vid.paused) { vid.play().catch(()=>{}); playBtn.className = "pi pi-pause"; } else { vid.pause(); playBtn.className = "pi pi-play"; } }; const scrubberContainer = document.createElement("div"); Object.assign(scrubberContainer.style, { flex: "1", height: "4px", background: "#444", borderRadius: "2px", position: "relative", cursor: "pointer" }); const scrubberFill = document.createElement("div"); Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "var(--primary, var(--p-primary-color, #38bdf8))", borderRadius: "2px" }); scrubberContainer.appendChild(scrubberFill); const timeLabel = document.createElement("span"); timeLabel.textContent = "0:00 / 0:00"; const scrub = (e) => { const rect = scrubberContainer.getBoundingClientRect(); const clientX = getClientX(e); const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width)); const duration = vid.duration || 0; if (duration > 0) { vid.currentTime = (percent / 100) * duration; scrubberFill.style.width = `${percent}%`; } }; let isScrubbing = false; scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); }; scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); }; const handleWindowMove = (e) => { if (isScrubbing) scrub(e); }; const handleWindowUp = () => { isScrubbing = false; }; window.addEventListener("mousemove", handleWindowMove); window.addEventListener("touchmove", handleWindowMove); window.addEventListener("mouseup", handleWindowUp); window.addEventListener("touchend", handleWindowUp); controlBar.append(playBtn, scrubberContainer, timeLabel); container.appendChild(controlBar); const syncLoop = () => { if (!videoSyncActive) return; const cur = vid.currentTime || 0; const dur = vid.duration || 0; if (dur > 0) { scrubberFill.style.width = `${(cur / dur) * 100}%`; const formatTime = (t) => { const m = Math.floor(t / 60); const s = Math.floor(t % 60); return `${m}:${s < 10 ? '0' : ''}${s}`; }; timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`; } playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause"; syncAnimationFrameId = requestAnimationFrame(syncLoop); }; requestAnimationFrame(syncLoop); return () => { videoSyncActive = false; if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId); window.removeEventListener("mousemove", handleWindowMove); window.removeEventListener("touchmove", handleWindowMove); window.removeEventListener("mouseup", handleWindowUp); window.removeEventListener("touchend", handleWindowUp); vid.removeEventListener("loadedmetadata", onMetaLoaded); controlBar.remove(); }; }; function createComparisonViewer(baseSrc, onDestroy = () => {}) { const isBaseVideo = isVideoFormat(baseSrc); let splitRatio = 50; let mediaB = null; const overlay = new SidebarOverlay({ onDestroy, onUserClose: () => { if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false); }, onKeyDown: (e) => { if (e.key === " " && mediaB) { e.preventDefault(); updateSliderPosition(splitRatio > 50 ? 0 : 100); } } }); // Inset 6px left/right so sidebar resize handles stay 100% accessible const scrollContainer = document.createElement("div"); Object.assign(scrollContainer.style, { position: "absolute", top: "0", left: "6px", right: "6px", width: "auto", height: "100%", display: "flex", overflow: "auto", boxSizing: "border-box", padding: "48px 24px", scrollbarWidth: "thin", scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15", pointerEvents: "auto" }); overlay.container.appendChild(scrollContainer); const header = document.createElement("div"); Object.assign(header.style, { position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)", display: "flex", gap: "16px", zIndex: "30", color: "var(--muted-foreground, var(--desc-color, #aaa))", fontSize: "12px", fontFamily: "sans-serif", pointerEvents: "none", background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)", border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))", padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(8px)", boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center" }); const infoText = document.createElement("span"); infoText.textContent = isBaseVideo ? t("Video playback. Press Esc to close.") : t("Click image to zoom (100%/Fit) | Shift+Click another card to compare."); header.appendChild(infoText); overlay.container.appendChild(header); const wrapper = document.createElement("div"); Object.assign(wrapper.style, { position: "relative", display: "grid", placeItems: "center", margin: "auto", flexShrink: "0", cursor: isBaseVideo ? "default" : "zoom-in", pointerEvents: "auto" }); scrollContainer.appendChild(wrapper); // Large 60px Lucide Spinner with relaxed 1.3s rotation const showPlaceholder = () => { mediaA.style.display = "none"; if (mediaB) { cleanupMedia(mediaB); mediaB.remove(); mediaB = null; } slider.style.display = "none"; let placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder"); if (!placeholder) { placeholder = document.createElement("div"); placeholder.className = "comfy-sidebar-viewer-placeholder"; Object.assign(placeholder.style, { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "36px", borderRadius: "12px", color: "#94a3b8", textAlign: "center", userSelect: "none", pointerEvents: "auto", minWidth: "120px", minHeight: "120px", boxSizing: "border-box", margin: "auto" }); wrapper.appendChild(placeholder); } const spinnerSvg = ` `; placeholder.innerHTML = spinnerSvg; placeholder.style.display = "flex"; wrapper.style.width = "auto"; wrapper.style.height = "auto"; wrapper.style.cursor = "default"; }; const hidePlaceholder = () => { const placeholder = wrapper.querySelector(".comfy-sidebar-viewer-placeholder"); if (placeholder) placeholder.style.display = "none"; mediaA.style.display = "block"; }; let mediaA = createMediaElement(baseSrc, false); mediaA.onerror = () => { showPlaceholder(); }; wrapper.appendChild(mediaA); let isZoomed = false; if (!baseSrc) { showPlaceholder(); } const slider = document.createElement("div"); slider.className = "comfy-sidebar-comparison-slider"; Object.assign(slider.style, { position: "absolute", top: "0", bottom: "0", left: "50%", width: "2px", background: "var(--foreground, #ffffff)", cursor: "ew-resize", zIndex: "25", display: "none", pointerEvents: "auto", boxShadow: "0 0 6px rgba(0,0,0,0.6)" }); const sliderButton = document.createElement("div"); sliderButton.className = "comfy-sidebar-comparison-handle"; Object.assign(sliderButton.style, { position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: "32px", height: "32px", borderRadius: "50%", background: "var(--card, var(--secondary, rgba(30, 30, 30, 0.9)))", border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))", color: "var(--foreground, #eee)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 4px 12px rgba(0,0,0,0.5)", pointerEvents: "auto", cursor: "ew-resize", transition: "background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out" }); sliderButton.innerHTML = ``; slider.appendChild(sliderButton); wrapper.appendChild(slider); const updateSliderPosition = (percent) => { splitRatio = Math.max(0, Math.min(100, percent)); slider.style.left = `${splitRatio}%`; if (mediaB) { mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`; } }; const cleanupMedia = (el) => { if (el && el.tagName === "VIDEO") { el.pause(); el.src = ""; el.load(); } }; const syncImageScales = () => { if (!mediaA) return; const wA = mediaA.naturalWidth || mediaA.videoWidth || 0; const hA = mediaA.naturalHeight || mediaA.videoHeight || 0; if (!wA || !hA) return; // Calculate available space inside the preview area const availW = Math.max(100, (scrollContainer.clientWidth || window.innerWidth) - 48); const availH = Math.max(100, (scrollContainer.clientHeight || window.innerHeight) - 96); const aspectA = wA / hA; // Scale to fit available space while maintaining aspect ratio (fills screen even for small previews) let fitW = availW; let fitH = fitW / aspectA; if (fitH > availH) { fitH = availH; fitW = fitH * aspectA; } fitW = Math.round(fitW); fitH = Math.round(fitH); if (!mediaB) { slider.style.display = "none"; wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in"); if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto"; if (isZoomed) { wrapper.style.maxWidth = "none"; wrapper.style.maxHeight = "none"; wrapper.style.width = `${wA}px`; wrapper.style.height = `${hA}px`; mediaA.style.maxWidth = "none"; mediaA.style.maxHeight = "none"; mediaA.style.width = "100%"; mediaA.style.height = "100%"; mediaA.style.objectFit = "fill"; } else { wrapper.style.maxWidth = "none"; wrapper.style.maxHeight = "none"; wrapper.style.width = `${fitW}px`; wrapper.style.height = `${fitH}px`; mediaA.style.maxWidth = "none"; mediaA.style.maxHeight = "none"; mediaA.style.width = "100%"; mediaA.style.height = "100%"; mediaA.style.objectFit = "fill"; } return; } const wB = mediaB.naturalWidth || mediaB.videoWidth || 0; const hB = mediaB.naturalHeight || mediaB.videoHeight || 0; if (!wB || !hB) return; const arA = wA / hA; const arB = wB / hB; const arDiff = Math.abs(arA - arB) / Math.max(arA, arB); if (arDiff > 0.05) { infoText.textContent = t("Aspect ratio mismatch ({wA}x{hA} vs {wB}x{hB}). Images must have matching proportions to compare.", { wA, hA, wB, hB }); infoText.style.color = "#f87171"; setTimeout(() => { if (infoText) { infoText.style.color = "#aaa"; infoText.textContent = t("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; } slider.style.display = "block"; wrapper.style.cursor = isBaseVideo ? "default" : (isZoomed ? "zoom-out" : "zoom-in"); if (mediaA) mediaA.style.pointerEvents = "none"; if (mediaB) mediaB.style.pointerEvents = "none"; infoText.style.color = "#aaa"; infoText.textContent = t("Click image to zoom | Drag image to compare slider | Esc to close."); updateSliderPosition(50); const maxW = Math.max(wA, wB); const maxH = Math.max(hA, hB); const aspectComp = maxW / maxH; let fitWComp = availW; let fitHComp = fitWComp / aspectComp; if (fitHComp > availH) { fitHComp = availH; fitWComp = fitHComp * aspectComp; } fitWComp = Math.round(fitWComp); fitHComp = Math.round(fitHComp); if (isZoomed) { wrapper.style.maxWidth = "none"; wrapper.style.maxHeight = "none"; wrapper.style.width = `${maxW}px`; wrapper.style.height = `${maxH}px`; [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 = "none"; wrapper.style.maxHeight = "none"; wrapper.style.width = `${fitWComp}px`; wrapper.style.height = `${fitHComp}px`; [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"; } }); } }; const onMediaReady = () => { hidePlaceholder(); syncImageScales(); }; if (mediaA.tagName === "VIDEO") { mediaA.addEventListener("loadedmetadata", onMediaReady); if (mediaA.readyState >= 1) onMediaReady(); } else { mediaA.onload = onMediaReady; if (mediaA.complete && mediaA.naturalWidth) onMediaReady(); } // Dynamically adjust preview scale if sidebars are resized if (window.ResizeObserver) { const ro = new ResizeObserver(() => { if (!isZoomed) syncImageScales(); }); ro.observe(scrollContainer); overlay.addCleanup(() => ro.disconnect()); } const toggleZoom = () => { if (isBaseVideo) return; isZoomed = !isZoomed; syncImageScales(); if (!isZoomed) { scrollContainer.scrollTop = 0; scrollContainer.scrollLeft = 0; } }; let isDraggingSlider = false, dragMoved = false, dragStartX = 0, dragStartY = 0; const startDrag = (e) => { if (e.button && e.button !== 0) return; dragStartX = getClientX(e); dragStartY = getClientY(e); dragMoved = false; isDraggingSlider = true; }; const doDrag = (e) => { if (!isDraggingSlider) return; const clientX = getClientX(e); const clientY = getClientY(e); // Distinguish click from drag: only snap when mouse moves more than 3px if (!dragMoved && (Math.abs(clientX - dragStartX) > 3 || Math.abs(clientY - dragStartY) > 3)) { dragMoved = true; } // Click + Drag: snap slider to mouse pointer and drag smoothly if (dragMoved && mediaB) { const rect = wrapper.getBoundingClientRect(); if (rect.width > 0) { const percent = ((clientX - rect.left) / rect.width) * 100; updateSliderPosition(percent); } } }; const endDrag = () => { if (!isDraggingSlider) return; isDraggingSlider = false; if (dragMoved) { // Keep dragMoved true briefly so the upcoming click event doesn't trigger toggleZoom setTimeout(() => { dragMoved = false; }, 60); } }; wrapper.addEventListener("mousedown", startDrag); wrapper.addEventListener("touchstart", startDrag); window.addEventListener("mousemove", doDrag); window.addEventListener("touchmove", doDrag); window.addEventListener("mouseup", endDrag); window.addEventListener("touchend", endDrag); overlay.addCleanup(() => { window.removeEventListener("mousemove", doDrag); window.removeEventListener("touchmove", doDrag); window.removeEventListener("mouseup", endDrag); window.removeEventListener("touchend", endDrag); }); wrapper.addEventListener("click", (e) => { if (dragMoved || isBaseVideo) return; toggleZoom(); }); scrollContainer.addEventListener("click", (e) => { if (dragMoved) return; if (e.target === scrollContainer || e.target === overlay.container) { overlay.onUserClose?.(); overlay.destroy(); } }); if (isBaseVideo) { const destroyVideoPlayback = setupVideoPlayback(mediaA, overlay.container); overlay.addCleanup(destroyVideoPlayback); } overlay.addCleanup(() => { cleanupMedia(mediaA); cleanupMedia(mediaB); }); return { is3D: false, isAudio: false, isText: false, loadTarget(targetSrc, isShiftClick) { const isTargetVideo = isVideoFormat(targetSrc); const isTarget3D = is3DFormat(targetSrc); const isTargetAudio = isAudioFormat(targetSrc); const isTargetText = typeof targetSrc === "object" && targetSrc.text; if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) { const currentPid = activeComparisonViewer?.targetPid; overlay.destroy(); showFullscreenPreview([targetSrc], isShiftClick, currentPid); return; } if (!targetSrc) { showPlaceholder(); return; } if (isShiftClick) { if (mediaB) { cleanupMedia(mediaB); mediaB.remove(); mediaB = null; } mediaB = createMediaElement(targetSrc, false); mediaB.style.pointerEvents = "none"; mediaB.onload = syncImageScales; wrapper.appendChild(mediaB); if (mediaB.complete && mediaB.naturalWidth) syncImageScales(); } else { if (mediaB) { cleanupMedia(mediaB); mediaB.remove(); mediaB = null; } slider.style.display = "none"; hidePlaceholder(); mediaA.src = targetSrc; mediaA.onload = () => { hidePlaceholder(); infoText.style.color = "#aaa"; infoText.textContent = t("Click image to zoom (100%/Fit) | Shift+Click another card to compare."); syncImageScales(); }; } }, destroy: () => overlay.destroy() }; } export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null) { if (!imgSrcs || imgSrcs.length === 0) return; const item = imgSrcs[0]; const targetPid = pid || (typeof item === "object" && item ? item.pid : null); const pidStr = targetPid ? String(targetPid) : null; // Automatically manage Runtime Preview mode based on the card clicked if (pidStr) { if (isLatestOrActiveTask(pidStr)) { if (!isRuntimePreviewActive) setRuntimePreviewEnabled(true, false); } else { if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false); } } const handleUserClose = () => { if (isRuntimePreviewActive) setRuntimePreviewEnabled(false, false); }; // Handle Text Output if (typeof item === "object" && item.text) { if (activeComparisonViewer) { if (activeComparisonViewer.isText) { activeComparisonViewer.targetPid = pidStr; activeComparisonViewer.loadTarget(item); return; } activeComparisonViewer.destroy(); activeComparisonViewer = null; } activeComparisonViewer = createTextReader( item, (target) => showFullscreenPreview([target], false, pidStr), () => { activeComparisonViewer = null; }, handleUserClose ); if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr; return; } const src = typeof item === "string" ? item : (item.url || item.filename || ""); // Delegate 3D formats if (is3DFormat(src)) { if (activeComparisonViewer) { if (activeComparisonViewer.is3D) { activeComparisonViewer.targetPid = pidStr; activeComparisonViewer.loadTarget(src); return; } activeComparisonViewer.destroy(); activeComparisonViewer = null; } activeComparisonViewer = create3DViewer( src, (targetSrc) => showFullscreenPreview([targetSrc], false, pidStr), () => { activeComparisonViewer = null; }, handleUserClose ); if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr; return; } // Delegate Audio formats if (isAudioFormat(src)) { if (activeComparisonViewer) { if (activeComparisonViewer.isAudio) { activeComparisonViewer.targetPid = pidStr; activeComparisonViewer.loadTarget(src); return; } activeComparisonViewer.destroy(); activeComparisonViewer = null; } activeComparisonViewer = createAudioViewer( src, (targetSrc) => showFullscreenPreview([targetSrc], false, pidStr), () => { activeComparisonViewer = null; } ); if (activeComparisonViewer) activeComparisonViewer.targetPid = pidStr; return; } // Delegate 2D/Video formats if (activeComparisonViewer) { if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) { activeComparisonViewer.destroy(); activeComparisonViewer = null; } else { activeComparisonViewer.targetPid = pidStr; activeComparisonViewer.loadTarget(src, isShiftClick); return; } } activeComparisonViewer = createComparisonViewer( src, () => { activeComparisonViewer = null; } ); if (activeComparisonViewer) { activeComparisonViewer.targetPid = pidStr; } }