1020 lines
38 KiB
JavaScript
1020 lines
38 KiB
JavaScript
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 = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
|
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
|
|
|
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 = `<i class="pi pi-replay"></i>`;
|
|
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 = `
|
|
<style>
|
|
@keyframes comfy-sidebar-spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--primary, var(--p-primary-color, #38bdf8)); animation: comfy-sidebar-spin 1.3s linear infinite; filter: drop-shadow(0 0 12px rgba(168, 85, 247, 0.45));">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
</svg>
|
|
`;
|
|
|
|
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 = `<span class="pi pi-arrows-h" style="font-size: 13px; pointer-events: none;"></span>`;
|
|
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;
|
|
}
|
|
} |