Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da5756d61c | ||
|
|
370f4fc58c |
+615
-86
@@ -1,16 +1,306 @@
|
||||
import { isVideoFormat, is3DFormat } from "./utils.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js";
|
||||
import { State } from "./state.js";
|
||||
import { create3DViewer } from "./viewer3d.js";
|
||||
import { createTextReader } from "./text_reader.js";
|
||||
import { stopAllAudioPlayback } from "./ui.js";
|
||||
|
||||
let activeComparisonViewer = null;
|
||||
let globalKeydownHandler = null;
|
||||
|
||||
// Safe clientX resolver for mouse and touch events
|
||||
export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
}
|
||||
|
||||
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 = () => {}) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-sidebar-comparison-overlay";
|
||||
Object.assign(container.style, {
|
||||
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: "1000",
|
||||
boxSizing: "border-box", overflow: "hidden", pointerEvents: "auto", userSelect: "none"
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "pi pi-times";
|
||||
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",
|
||||
background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px"
|
||||
});
|
||||
closeBtn.onmouseenter = () => closeBtn.style.color = "#fff";
|
||||
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
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"
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Waveform visualization
|
||||
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);
|
||||
});
|
||||
|
||||
// Scrubber
|
||||
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);
|
||||
|
||||
// Controls Row
|
||||
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" });
|
||||
|
||||
// Play & Stop SVGs
|
||||
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 = "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);
|
||||
container.appendChild(playerBox);
|
||||
|
||||
const audioEl = document.createElement("audio");
|
||||
audioEl.src = baseSrc;
|
||||
audioEl.preload = "metadata";
|
||||
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%";
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
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 = togglePlay;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
window.removeEventListener("resize", updateOverlayBounds);
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
audioEl.pause();
|
||||
audioEl.src = "";
|
||||
container.remove();
|
||||
onDestroy();
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container) destroy();
|
||||
};
|
||||
|
||||
const handleKeys = (e) => {
|
||||
if (e.key === "Escape") destroy();
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
};
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
document.body.appendChild(container);
|
||||
audioEl.play().then(() => {
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
playerBox.classList.add("playing");
|
||||
}).catch(()=>{});
|
||||
|
||||
return {
|
||||
isAudio: true,
|
||||
loadTarget(targetSrc) {
|
||||
if (!isAudioFormat(targetSrc)) {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
const createMediaElement = (src, muted = false) => {
|
||||
const isVideo = isVideoFormat(src);
|
||||
const el = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
@@ -23,7 +313,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;
|
||||
@@ -137,49 +428,90 @@ const setupVideoPlayback = (vid, container) => {
|
||||
};
|
||||
};
|
||||
|
||||
function createComparisonViewer(baseSrc) {
|
||||
function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
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"
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 +520,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 <span style="color:#aaa;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
|
||||
if (isBaseVideo) {
|
||||
@@ -204,22 +538,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 +565,6 @@ function createComparisonViewer(baseSrc) {
|
||||
wrapper.appendChild(slider);
|
||||
|
||||
let splitRatio = 50;
|
||||
let isDragging = false;
|
||||
let destroyVideoPlaybackFn = null;
|
||||
|
||||
const updateSliderPosition = (percent) => {
|
||||
@@ -253,27 +575,162 @@ function createComparisonViewer(baseSrc) {
|
||||
}
|
||||
};
|
||||
|
||||
const syncImageScales = () => {
|
||||
if (!mediaA) return;
|
||||
|
||||
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
|
||||
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
|
||||
|
||||
if (!mediaB) {
|
||||
slider.style.display = "none";
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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 +741,18 @@ function createComparisonViewer(baseSrc) {
|
||||
window.addEventListener("mouseup", endDrag);
|
||||
window.addEventListener("touchend", endDrag);
|
||||
|
||||
wrapper.addEventListener("click", (e) => {
|
||||
if (dragMoved || isBaseVideo) return;
|
||||
toggleZoom();
|
||||
});
|
||||
|
||||
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 +762,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);
|
||||
@@ -309,7 +784,7 @@ function createComparisonViewer(baseSrc) {
|
||||
cleanupMedia(mediaB);
|
||||
|
||||
container.remove();
|
||||
activeComparisonViewer = null;
|
||||
onDestroy();
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
@@ -325,39 +800,44 @@ function createComparisonViewer(baseSrc) {
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
targetContainer.appendChild(container);
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (isBaseVideo) {
|
||||
destroyVideoPlaybackFn = setupVideoPlayback(mediaA, container);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
|
||||
destroy();
|
||||
showFullscreenPreview([targetSrc], isShiftClick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTargetVideo) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
syncImageScales();
|
||||
}
|
||||
} else {
|
||||
if (mediaB) {
|
||||
cleanupMedia(mediaB);
|
||||
@@ -366,42 +846,91 @@ 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
const src = imgSrcs[0];
|
||||
const item = imgSrcs[0];
|
||||
|
||||
// Handle 3D assets natively or by focusing 3D node
|
||||
if (is3DFormat(src)) {
|
||||
if (window.app?.ui?.show3DViewer) {
|
||||
window.app.ui.show3DViewer(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.graph && app.canvas) {
|
||||
const nodes = app.graph._nodes || [];
|
||||
const node = nodes.find(n =>
|
||||
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
|
||||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
|
||||
);
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
// Handle Text Output
|
||||
if (typeof item === "object" && item.text) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isText) {
|
||||
activeComparisonViewer.loadTarget(item);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = createTextReader(
|
||||
item,
|
||||
(target) => showFullscreenPreview([target]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const src = typeof item === "string" ? item : (item.url || item.filename || "");
|
||||
|
||||
// Delegate 3D formats
|
||||
if (is3DFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.is3D) {
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = create3DViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate Audio formats
|
||||
if (isAudioFormat(src)) {
|
||||
if (activeComparisonViewer) {
|
||||
if (activeComparisonViewer.isAudio) {
|
||||
activeComparisonViewer.loadTarget(src);
|
||||
return;
|
||||
}
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
}
|
||||
activeComparisonViewer = createAudioViewer(
|
||||
src,
|
||||
(targetSrc) => showFullscreenPreview([targetSrc]),
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate 2D/Video formats
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
|
||||
activeComparisonViewer.destroy();
|
||||
activeComparisonViewer = null;
|
||||
} else {
|
||||
activeComparisonViewer.loadTarget(src, isShiftClick);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
activeComparisonViewer = createComparisonViewer(src);
|
||||
activeComparisonViewer = createComparisonViewer(
|
||||
src,
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
}
|
||||
Vendored
+10
-5
@@ -40,7 +40,7 @@ export function setupDragAndDrop() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas || !canvas.graph) return;
|
||||
|
||||
// 1. Check for workflow JSON drop (text-only and unfinished cards)
|
||||
// 1. Check for workflow JSON drop
|
||||
const jsonStr = e.dataTransfer.getData("application/json");
|
||||
if (jsonStr) {
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ export function setupDragAndDrop() {
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// 2. Check for image / video / 3D URL drop
|
||||
// 2. Check for image / video / 3D / audio URL drop
|
||||
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
|
||||
if (url) {
|
||||
try {
|
||||
@@ -82,9 +82,11 @@ export function setupDragAndDrop() {
|
||||
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
|
||||
}
|
||||
|
||||
// Check if dropped on a LoadImage, LoadVideo, Load3D, VHS_LoadVideo, or image/video/3D widget node
|
||||
// Check if dropped on a LoadImage, LoadVideo, Load3D, LoadAudio, or widget node
|
||||
const widget = targetNode?.widgets?.find(w =>
|
||||
w.name === "image" || w.name === "video" || w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" || w.type === "image" || w.type === "customtext"
|
||||
w.name === "image" || w.name === "video" || w.name === "audio" || w.name === "audio_file" ||
|
||||
w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" ||
|
||||
w.type === "image" || w.type === "customtext"
|
||||
);
|
||||
|
||||
const isLoadNode = targetNode && (
|
||||
@@ -94,6 +96,9 @@ export function setupDragAndDrop() {
|
||||
targetNode.type?.includes("VHS_LoadVideo") ||
|
||||
targetNode.type?.includes("Load3D") ||
|
||||
targetNode.type?.includes("Load 3D") ||
|
||||
targetNode.type?.includes("LoadAudio") ||
|
||||
targetNode.type?.includes("Load Audio") ||
|
||||
targetNode.type?.includes("VHS_LoadAudio") ||
|
||||
targetNode.type?.includes("Preview3D")
|
||||
);
|
||||
|
||||
@@ -115,7 +120,7 @@ export function setupDragAndDrop() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dropped on empty canvas area: fetch file and load embedded workflow metadata
|
||||
// Dropped on empty canvas area
|
||||
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const res = await fetch(src);
|
||||
if (res.ok) {
|
||||
|
||||
@@ -9,7 +9,6 @@ let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
|
||||
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
@@ -26,7 +25,6 @@ function findHeaderByText(parent, text) {
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
// Robust multi-selector search for Vue node containers across different frontend versions
|
||||
const nodeEl = document.querySelector(
|
||||
`.comfy-node[data-node-id="${node.id}"], ` +
|
||||
`[data-node-id="${node.id}"], ` +
|
||||
@@ -175,7 +173,6 @@ app.registerExtension({
|
||||
|
||||
init() {
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Keep Object Aspect Ratio", name: "If disabled, cards in the queue will be cropped to the same size.", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
@@ -283,16 +280,13 @@ app.registerExtension({
|
||||
|
||||
await initSessionAndHistory();
|
||||
|
||||
// Watch for Vue DOM node mounting events (e.g. LiteGraph ↔ Vue mode switches)
|
||||
setupVueNodeObserver();
|
||||
|
||||
// Remove any existing keydown listener from previous hot-reload
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
// Clean, direct keyboard listener
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (
|
||||
@@ -302,7 +296,6 @@ app.registerExtension({
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
// Toggle sidebar with Q
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -310,7 +303,6 @@ app.registerExtension({
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
// Toggle ignore node with Ctrl+Q or Cmd+Q (Mac)
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -320,7 +312,6 @@ app.registerExtension({
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
// Apply sidebar override when initializing
|
||||
applySidebarOverride();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
|
||||
+105
-5
@@ -72,11 +72,11 @@ export function injectStyles() {
|
||||
display: inline-flex;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||
color: #e2e8f0 !important;
|
||||
font-size: 14px !important;
|
||||
font-size: 13px !important;
|
||||
border-radius: 6px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
@@ -129,8 +129,8 @@ export function injectStyles() {
|
||||
/* Scroll To Top Floating Button */
|
||||
.comfy-sidebar-scroll-top-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
bottom: 14px;
|
||||
right: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
@@ -249,6 +249,106 @@ export function injectStyles() {
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Audio Preview Cards */
|
||||
.comfy-sidebar-audio-wrapper {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
background: linear-gradient(135deg, #1e1333 0%, #0f172a 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px 12px 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper:hover {
|
||||
border-color: #a855f7;
|
||||
box-shadow: 0 0 12px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-badge {
|
||||
background: rgba(168, 85, 247, 0.9);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-title {
|
||||
font-size: 10px;
|
||||
color: #f1f5f9;
|
||||
font-family: monospace;
|
||||
max-width: 65%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.comfy-sidebar-soundwave-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 36px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.comfy-sidebar-soundwave-bar {
|
||||
width: 3px;
|
||||
background: #a855f7;
|
||||
border-radius: 2px;
|
||||
height: 8px;
|
||||
transition: height 0.1s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.comfy-sidebar-audio-wrapper.playing .comfy-sidebar-soundwave-bar {
|
||||
background: #c084fc;
|
||||
animation: comfySidebarWave 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes comfySidebarWave {
|
||||
0% { transform: scaleY(0.4); }
|
||||
100% { transform: scaleY(1.3); }
|
||||
}
|
||||
|
||||
/* Text Output Card Clamping */
|
||||
.comfy-sidebar-text-clamp {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #cbd5e1;
|
||||
max-height: 84px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-top: 18px;
|
||||
word-break: break-word;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.comfy-sidebar-text-clamp:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { State } from "./state.js";
|
||||
|
||||
let globalKeydownHandler = null;
|
||||
|
||||
// Global helper for code block copying in Markdown view
|
||||
window.moonCopyCode = function(button) {
|
||||
try {
|
||||
const container = button.parentElement;
|
||||
if (!container) return;
|
||||
const codeEl = container.querySelector("code");
|
||||
if (!codeEl) return;
|
||||
const text = codeEl.innerText;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
button.innerText = "Copied!";
|
||||
button.style.backgroundColor = "#4CAF50";
|
||||
button.style.borderColor = "#45a049";
|
||||
button.style.color = "#ffffff";
|
||||
setTimeout(() => {
|
||||
button.innerText = "Copy";
|
||||
button.style.backgroundColor = "#333333";
|
||||
button.style.borderColor = "#555555";
|
||||
button.style.color = "#aaaaaa";
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
button.innerText = "Error";
|
||||
setTimeout(() => button.innerText = "Copy", 1500);
|
||||
});
|
||||
} catch (e) {
|
||||
button.innerText = "Error";
|
||||
setTimeout(() => button.innerText = "Copy", 1500);
|
||||
}
|
||||
};
|
||||
|
||||
function parseInlineMarkdown(html) {
|
||||
if (!html) return "";
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:#252525;padding:2px 6px;border-radius:3px;font-family:monospace;color:#e06c75;border:1px solid #333;font-size:12px;">$1</code>');
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong style="color:#ffffff;font-weight:600;"><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;font-weight:600;">$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<span style="text-decoration:line-through;color:#888;">$1</span>');
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#61afef;text-decoration:none;border-bottom:1px dashed #61afef;cursor:pointer;">$1</a>');
|
||||
html = html.replace(/\$(?!\s)([^\$]+?)(?<!\s)\$/g, '<span style="font-family:\'Times New Roman\',Times,serif;font-size:14px;color:#e5c07b;font-style:italic;padding:0 2px;">$1</span>');
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseMarkdown(text) {
|
||||
if (!text) return "";
|
||||
let rawLines = text.replace(/&/g, "&").replace(/</g, "<").split("\n");
|
||||
let htmlLines = [];
|
||||
let inCodeBlock = false, codeContent = [], codeLang = "";
|
||||
let inUl = false, inOl = false, inTable = false;
|
||||
let tableHeaders = [], tableAlignments = [], tableRows = [];
|
||||
|
||||
function closeUl() { if (inUl) { htmlLines.push("</ul>"); inUl = false; } }
|
||||
function closeOl() { if (inOl) { htmlLines.push("</ol>"); inOl = false; } }
|
||||
function closeTable() {
|
||||
if (inTable) {
|
||||
let tableHtml = `<table style="border-collapse:collapse;width:100%;margin:12px 0;font-size:12px;border:1px solid #333;color:#ddd;text-align:left;">`;
|
||||
if (tableHeaders.length > 0) {
|
||||
tableHtml += `<thead style="background:#1a1a1a;font-weight:600;color:#fff;border-bottom:2px solid #444;"><tr>`;
|
||||
for (let i = 0; i < tableHeaders.length; i++) {
|
||||
const align = tableAlignments[i] || 'left';
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(tableHeaders[i])}</th>`;
|
||||
}
|
||||
tableHtml += `</tr></thead>`;
|
||||
}
|
||||
tableHtml += "<tbody>";
|
||||
for (let r = 0; r < tableRows.length; r++) {
|
||||
const row = tableRows[r];
|
||||
const bg = r % 2 === 0 ? 'rgba(255,255,255,0.015)' : 'transparent';
|
||||
tableHtml += `<tr style="background:${bg};border-bottom:1px solid #222;">`;
|
||||
for (let i = 0; i < tableHeaders.length; i++) {
|
||||
const align = tableAlignments[i] || 'left';
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #333;text-align:${align};">${parseInlineMarkdown(row[i] || '')}</td>`;
|
||||
}
|
||||
tableHtml += `</tr>`;
|
||||
}
|
||||
tableHtml += "</tbody></table>";
|
||||
htmlLines.push(tableHtml);
|
||||
inTable = false;
|
||||
tableHeaders = []; tableAlignments = []; tableRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let line of rawLines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
if (inCodeBlock) {
|
||||
const rCode = codeContent.join("\n");
|
||||
const langBadge = codeLang ? `<div style="position:absolute;top:6px;left:10px;color:#85c5ec;font-size:10px;font-family:sans-serif;font-weight:bold;text-transform:uppercase;opacity:0.8;user-select:none;">${codeLang}</div>` : '';
|
||||
const btnStyle = "position:absolute;top:6px;right:6px;background:#333;color:#aaa;border:1px solid #555;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;";
|
||||
htmlLines.push(`<div class="code-block-container" style="position:relative;margin:8px 0;">${langBadge}<button onclick="window.moonCopyCode(this)" style="${btnStyle}">Copy</button><pre style="background:#1e1e1e;padding:10px;padding-top:28px;border-radius:4px;border:1px solid #333;font-family:monospace;overflow-x:auto;margin:0;color:#85c5ec;"><code>${rCode}</code></pre></div>`);
|
||||
codeContent = []; codeLang = ""; inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true; codeLang = trimmed.slice(3).trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) { codeContent.push(line); continue; }
|
||||
|
||||
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
htmlLines.push(`<hr style="border:0;border-top:1px solid #333;margin:16px 0;">`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("$$") && trimmed.endsWith("$$")) {
|
||||
closeUl(); closeOl(); closeTable();
|
||||
htmlLines.push(`<div style="text-align:center;margin:12px 0;font-family:'Times New Roman',Times,serif;font-size:16px;color:#e5c07b;background:rgba(255,255,255,0.01);padding:10px;border-radius:4px;border:1px dashed #3e4451;font-style:italic;">${trimmed.slice(2, -2).trim()}</div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
|
||||
closeUl(); closeOl();
|
||||
const isSeparator = /^\|[\s\-\:\s\|]+\|$/.test(trimmed);
|
||||
const cells = trimmed.split("|").slice(1, -1).map(c => c.trim());
|
||||
if (isSeparator) {
|
||||
tableAlignments = cells.map(c => (c.startsWith(":") && c.endsWith(":")) ? "center" : c.endsWith(":") ? "right" : "left");
|
||||
inTable = true;
|
||||
} else {
|
||||
if (!inTable) { tableHeaders = cells; inTable = true; } else { tableRows.push(cells); }
|
||||
}
|
||||
continue;
|
||||
} else { closeTable(); }
|
||||
|
||||
if (trimmed.startsWith(">")) {
|
||||
closeUl(); closeOl();
|
||||
const match = trimmed.match(/^([>\s]+)(.*)/);
|
||||
if (match) {
|
||||
const depth = (match[1].match(/>/g) || []).length;
|
||||
let quote = parseInlineMarkdown(match[2].trim());
|
||||
for (let d = 0; d < depth; d++) {
|
||||
quote = `<blockquote style="border-left:4px solid #4CAF50;margin:6px 0;padding:6px 12px;color:#aaa;font-style:italic;background-color:rgba(255,255,255,0.015);">${quote}</blockquote>`;
|
||||
}
|
||||
htmlLines.push(quote);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("### ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h3 style="margin:12px 0 6px 0;color:#ffffff;font-weight:600;font-size:14px;">${parseInlineMarkdown(line.slice(4))}</h3>`);
|
||||
continue;
|
||||
} else if (line.startsWith("## ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h2 style="margin:16px 0 8px 0;color:#ffffff;font-weight:600;font-size:16px;border-bottom:1px solid #333;padding-bottom:4px;">${parseInlineMarkdown(line.slice(3))}</h2>`);
|
||||
continue;
|
||||
} else if (line.startsWith("# ")) {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<h1 style="margin:20px 0 10px 0;color:#ffffff;font-weight:700;font-size:18px;border-bottom:2px solid #444;padding-bottom:6px;">${parseInlineMarkdown(line.slice(2))}</h1>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ulMatch = line.match(/^(\s*)([-*])\s(.*)/);
|
||||
if (ulMatch) {
|
||||
closeUl();
|
||||
if (!inUl) { htmlLines.push(`<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">`); inUl = true; }
|
||||
const indent = ulMatch[1].length;
|
||||
const content = ulMatch[3].trim();
|
||||
const paddingLeft = indent > 0 ? `${indent * 8}px` : "0px";
|
||||
if (content.startsWith("[ ]")) {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
|
||||
} else if (content.toLowerCase().startsWith("[x]")) {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;list-style-type:none;"><input type="checkbox" checked disabled style="margin-right:6px;vertical-align:middle;">${parseInlineMarkdown(content.slice(3).trim())}</li>`);
|
||||
} else {
|
||||
htmlLines.push(`<li style="margin-left:${paddingLeft};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(content)}</li>`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const olMatch = line.match(/^(\s*)(\d+)\.\s(.*)/);
|
||||
if (olMatch) {
|
||||
closeUl();
|
||||
if (!inOl) { htmlLines.push(`<ol style="margin:8px 0;padding-left:24px;list-style-type:decimal;">`); inOl = true; }
|
||||
const indent = olMatch[1].length;
|
||||
htmlLines.push(`<li style="margin-left:${indent > 0 ? `${indent * 8}px` : "0px"};margin-bottom:4px;color:#ddd;padding-left:4px;">${parseInlineMarkdown(olMatch[3].trim())}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push("<br>");
|
||||
} else {
|
||||
closeUl(); closeOl();
|
||||
htmlLines.push(`<p style="margin:6px 0;color:#ddd;line-height:1.6;">${parseInlineMarkdown(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeUl(); closeOl(); closeTable();
|
||||
return htmlLines.join("\n");
|
||||
}
|
||||
|
||||
export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
let rawText = typeof textData === "object" ? (textData.text || "") : String(textData);
|
||||
let pid = typeof textData === "object" ? textData.pid : "";
|
||||
let isMarkdownMode = true;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-sidebar-comparison-overlay";
|
||||
Object.assign(container.style, {
|
||||
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: "1000",
|
||||
boxSizing: "border-box", overflow: "hidden", pointerEvents: "auto", userSelect: "none"
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "pi pi-times";
|
||||
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",
|
||||
background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px"
|
||||
});
|
||||
closeBtn.onmouseenter = () => closeBtn.style.color = "#fff";
|
||||
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
const readerBox = document.createElement("div");
|
||||
Object.assign(readerBox.style, {
|
||||
background: "rgba(18, 18, 24, 0.95)", backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)", borderRadius: "12px",
|
||||
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.8)", display: "flex",
|
||||
flexDirection: "column", gap: "14px", zIndex: "20"
|
||||
});
|
||||
|
||||
const headerRow = document.createElement("div");
|
||||
Object.assign(headerRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: "12px" });
|
||||
|
||||
const titleInfo = document.createElement("div");
|
||||
Object.assign(titleInfo.style, { display: "flex", alignItems: "center", gap: "10px" });
|
||||
const title = document.createElement("span");
|
||||
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
|
||||
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f8fafc", whiteSpace: "nowrap" });
|
||||
|
||||
const statsBadge = document.createElement("span");
|
||||
Object.assign(statsBadge.style, { fontSize: "10px", color: "#94a3b8", background: "rgba(255,255,255,0.06)", padding: "3px 8px", borderRadius: "4px", fontFamily: "monospace", whiteSpace: "nowrap" });
|
||||
|
||||
titleInfo.append(title, statsBadge);
|
||||
|
||||
const actionsRow = document.createElement("div");
|
||||
Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center" });
|
||||
|
||||
const modeBtn = document.createElement("button");
|
||||
Object.assign(modeBtn.style, {
|
||||
background: "#334155", border: "1px solid #64748b", color: "#f8fafc",
|
||||
borderRadius: "6px", padding: "5px 12px", fontSize: "11px", cursor: "pointer",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
|
||||
whiteSpace: "nowrap", minWidth: "96px", height: "28px", boxSizing: "border-box"
|
||||
});
|
||||
modeBtn.innerHTML = `<i class="pi pi-code"></i><span>Markdown</span>`;
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
Object.assign(copyBtn.style, {
|
||||
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
|
||||
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
|
||||
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
|
||||
});
|
||||
copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`;
|
||||
|
||||
const downloadBtn = document.createElement("button");
|
||||
Object.assign(downloadBtn.style, {
|
||||
background: "#1e293b", border: "1px solid #475569", color: "#e2e8f0",
|
||||
borderRadius: "6px", padding: "5px 10px", fontSize: "11px", cursor: "pointer",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
|
||||
whiteSpace: "nowrap", height: "28px", boxSizing: "border-box"
|
||||
});
|
||||
downloadBtn.innerHTML = `<i class="pi pi-download"></i><span>.txt</span>`;
|
||||
|
||||
actionsRow.append(modeBtn, copyBtn, downloadBtn);
|
||||
headerRow.append(titleInfo, actionsRow);
|
||||
|
||||
const textArea = document.createElement("div");
|
||||
Object.assign(textArea.style, {
|
||||
flex: "1", overflowY: "auto", fontSize: "13px",
|
||||
lineHeight: "1.6", color: "#e2e8f0", wordBreak: "break-word",
|
||||
userSelect: "text", "-webkit-user-select": "text", background: "rgba(0,0,0,0.35)",
|
||||
padding: "16px 20px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.06)",
|
||||
scrollbarWidth: "thin", scrollbarColor: "#475569 rgba(0,0,0,0.2)"
|
||||
});
|
||||
|
||||
const updateView = () => {
|
||||
const wc = rawText.trim() ? rawText.trim().split(/\s+/).length : 0;
|
||||
statsBadge.textContent = `${wc} words • ${rawText.length} chars`;
|
||||
|
||||
if (isMarkdownMode) {
|
||||
modeBtn.innerHTML = `<i class="pi pi-code"></i><span>Markdown</span>`;
|
||||
modeBtn.style.background = "#3b82f6";
|
||||
modeBtn.style.borderColor = "#60a5fa";
|
||||
textArea.style.fontFamily = "sans-serif";
|
||||
textArea.style.whiteSpace = "normal";
|
||||
textArea.innerHTML = parseMarkdown(rawText);
|
||||
} else {
|
||||
modeBtn.innerHTML = `<i class="pi pi-align-left"></i><span>Raw Text</span>`;
|
||||
modeBtn.style.background = "#334155";
|
||||
modeBtn.style.borderColor = "#64748b";
|
||||
textArea.style.fontFamily = "monospace";
|
||||
textArea.style.whiteSpace = "pre-wrap";
|
||||
textArea.textContent = rawText;
|
||||
}
|
||||
};
|
||||
|
||||
modeBtn.onclick = () => {
|
||||
isMarkdownMode = !isMarkdownMode;
|
||||
updateView();
|
||||
};
|
||||
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(rawText);
|
||||
copyBtn.innerHTML = `<i class="pi pi-check" style="color:#10b981;"></i><span style="color:#10b981;">Copied!</span>`;
|
||||
setTimeout(() => { copyBtn.innerHTML = `<i class="pi pi-copy"></i><span>Copy</span>`; }, 1800);
|
||||
};
|
||||
|
||||
downloadBtn.onclick = () => {
|
||||
const blob = new Blob([rawText], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `output_${pid || "text"}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
updateView();
|
||||
|
||||
readerBox.append(headerRow, textArea);
|
||||
container.appendChild(readerBox);
|
||||
|
||||
const destroy = () => {
|
||||
window.removeEventListener("resize", updateOverlayBounds);
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
container.remove();
|
||||
onDestroy();
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
container.onclick = (e) => { if (e.target === container) destroy(); };
|
||||
|
||||
const handleKeys = (e) => { if (e.key === "Escape") destroy(); };
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
return {
|
||||
isText: true,
|
||||
loadTarget(targetData) {
|
||||
if (typeof targetData !== "object" || !targetData.text) {
|
||||
destroy();
|
||||
onSwitchMedia(targetData);
|
||||
return;
|
||||
}
|
||||
rawText = targetData.text || "";
|
||||
pid = targetData.pid || "";
|
||||
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
|
||||
updateView();
|
||||
},
|
||||
destroy
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, cardElements, saveStatesToLocalStorage, deletePromptState } from "./state.js";
|
||||
import { isVideoFormat, is3DFormat, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { showFullscreenPreview } from "./comparison.js";
|
||||
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js";
|
||||
import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js";
|
||||
|
||||
export let syncQueueFn = async () => {};
|
||||
export function setSyncQueue(fn) { syncQueueFn = fn; }
|
||||
|
||||
// Global audio playback manager: ensures only 1 audio stream plays at a time
|
||||
let currentlyPlayingAudio = null;
|
||||
export function stopAllAudioPlayback() {
|
||||
if (currentlyPlayingAudio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
currentlyPlayingAudio.currentTime = 0;
|
||||
if (currentlyPlayingAudio._onResetUI) currentlyPlayingAudio._onResetUI();
|
||||
currentlyPlayingAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardElements.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
@@ -67,20 +78,6 @@ function findNodeIdForImage(state, img) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNodeIdFor3DAsset(state, img) {
|
||||
const exactNodeId = findNodeIdForImage(state, img);
|
||||
if (exactNodeId) return exactNodeId;
|
||||
|
||||
if (app.graph && app.graph._nodes) {
|
||||
const node = app.graph._nodes.find(n =>
|
||||
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
|
||||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
|
||||
);
|
||||
if (node) return String(node.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const updateSidebarBadge = (count) => {
|
||||
const icons = document.querySelectorAll('.pi-images');
|
||||
icons.forEach(icon => {
|
||||
@@ -407,8 +404,7 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
|
||||
// Find the LAST 2D image generated in the same prompt run to use as background thumbnail
|
||||
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(i.filename || i.url)) || [];
|
||||
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(i.filename || i.url) && !isAudioFormat(i.filename || i.url)) || [];
|
||||
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
|
||||
const bgImgSrc = lastImageAsset
|
||||
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
|
||||
@@ -464,29 +460,9 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
|
||||
preview3D.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
// 1. Try Native Frontend 3D Viewer if available
|
||||
if (window.app?.ui?.show3DViewer) {
|
||||
window.app.ui.show3DViewer(fullUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Focus and select 3D node on workflow canvas
|
||||
const nodeId = findNodeIdFor3DAsset(state, img);
|
||||
if (nodeId && app.graph && app.canvas) {
|
||||
const node = app.graph.getNodeById(Number(nodeId));
|
||||
if (node) {
|
||||
app.canvas.centerOnNode(node);
|
||||
app.canvas.selectNode(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to comparison/fullscreen modal handler
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
};
|
||||
|
||||
// Configure drag & drop for 3D files
|
||||
preview3D.setAttribute("draggable", "true");
|
||||
preview3D.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -505,7 +481,195 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderCardImages(cardObj, state, keepAspect) {
|
||||
function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-wrapper");
|
||||
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const filename = img.filename || getFilenameFromUrl(src) || "audio.wav";
|
||||
const ext = filename.split('.').pop().toUpperCase();
|
||||
|
||||
// Solid white SVGs for exact video card style consistency
|
||||
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>`;
|
||||
|
||||
if (!previewAudio) {
|
||||
wrapper.innerHTML = "";
|
||||
previewAudio = document.createElement("div");
|
||||
previewAudio.className = "comfy-sidebar-audio-wrapper";
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-audio-title";
|
||||
title.textContent = filename;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-audio-badge";
|
||||
badge.textContent = ext || "AUDIO";
|
||||
|
||||
topRow.append(title, badge);
|
||||
|
||||
// Center Area: Waveform + Centered Play Button
|
||||
const centerArea = document.createElement("div");
|
||||
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" });
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
soundwave.style.width = "100%";
|
||||
const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12];
|
||||
barHeights.forEach((h, i) => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "comfy-sidebar-soundwave-bar";
|
||||
bar.style.height = `${h}px`;
|
||||
bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`;
|
||||
soundwave.appendChild(bar);
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
Object.assign(playBtn.style, {
|
||||
position: "absolute", width: "36px", height: "36px", borderRadius: "50%",
|
||||
background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5",
|
||||
transition: "all 0.15s ease", outline: "none"
|
||||
});
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
|
||||
centerArea.append(soundwave, playBtn);
|
||||
|
||||
// Bottom Row: Scrubber + Centered Time Label (with side padding so corner hover buttons don't block controls)
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" });
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
Object.assign(scrubber.style, {
|
||||
width: "100%", height: "4px", background: "#334155", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, {
|
||||
width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px"
|
||||
});
|
||||
scrubber.appendChild(scrubberFill);
|
||||
|
||||
const timeLabel = document.createElement("div");
|
||||
Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" });
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
bottomRow.append(scrubber, timeLabel);
|
||||
|
||||
const audioEl = document.createElement("audio");
|
||||
audioEl.src = fullUrl;
|
||||
audioEl.preload = "metadata";
|
||||
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
|
||||
const resetAudioUI = () => {
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
previewAudio.classList.remove("playing");
|
||||
scrubberFill.style.width = "0%";
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl._onResetUI = resetAudioUI;
|
||||
|
||||
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 = () => {
|
||||
resetAudioUI();
|
||||
if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null;
|
||||
};
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// If the fullscreen audio player is open, switch the modal player to this track
|
||||
if (isAudioViewerOpen()) {
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioEl.paused) {
|
||||
stopAllAudioPlayback();
|
||||
currentlyPlayingAudio = audioEl;
|
||||
audioEl.play().catch(()=>{});
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
previewAudio.classList.add("playing");
|
||||
} else {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
resetAudioUI();
|
||||
if (currentlyPlayingAudio === audioEl) currentlyPlayingAudio = null;
|
||||
}
|
||||
};
|
||||
|
||||
playBtn.onclick = togglePlay;
|
||||
|
||||
playBtn.onmouseenter = () => {
|
||||
playBtn.style.background = "#9333ea";
|
||||
playBtn.style.borderColor = "#c084fc";
|
||||
playBtn.style.transform = "scale(1.08)";
|
||||
};
|
||||
playBtn.onmouseleave = () => {
|
||||
playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)";
|
||||
playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)";
|
||||
playBtn.style.transform = "scale(1.0)";
|
||||
};
|
||||
|
||||
scrubber.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
if (audioEl.duration > 0) {
|
||||
audioEl.currentTime = pos * audioEl.duration;
|
||||
}
|
||||
};
|
||||
|
||||
previewAudio.append(topRow, centerArea, bottomRow, audioEl);
|
||||
wrapper.appendChild(previewAudio);
|
||||
|
||||
// Clicking the card stops any other audio and launches the fullscreen player
|
||||
previewAudio.onclick = (e) => {
|
||||
if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return;
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
};
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = previewAudio;
|
||||
|
||||
previewAudio.setAttribute("draggable", "true");
|
||||
previewAudio.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const mimeType = "audio/wav";
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullUrl);
|
||||
e.dataTransfer.setData("text/plain", fullUrl);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
}
|
||||
|
||||
function renderCardImages(cardObj, state) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
@@ -523,6 +687,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const isVideo = isVideoFormat(src);
|
||||
const is3D = is3DFormat(src) || is3DFormat(img.filename);
|
||||
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
|
||||
|
||||
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
|
||||
if (!wrapper) {
|
||||
@@ -538,6 +703,11 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
renderAudioCardPreview(cardObj, wrapper, src, img, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaEl = wrapper.querySelector("img, video");
|
||||
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
|
||||
|
||||
@@ -562,9 +732,8 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
showFullscreenPreview([src], ev.shiftKey);
|
||||
};
|
||||
|
||||
// Auto-prune card ONLY if file returns 404 Not Found from server
|
||||
mediaEl.onerror = async () => {
|
||||
if (src && !src.startsWith("blob:") && !is3D) {
|
||||
if (src && !src.startsWith("blob:") && !is3D && !isAudio) {
|
||||
try {
|
||||
const res = await fetch(src, { method: "HEAD" });
|
||||
if (res.status === 404) {
|
||||
@@ -573,9 +742,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
if (cardObj.element) cardObj.element.remove();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore network error - keep card
|
||||
}
|
||||
} catch (e) {}
|
||||
if (cardObj.placeholder) {
|
||||
cardObj.placeholder.textContent = "Error loading media preview";
|
||||
cardObj.placeholder.style.display = "block";
|
||||
@@ -584,16 +751,13 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
if (keepAspect && width && height) {
|
||||
mediaEl.style.aspectRatio = `${width} / ${height}`;
|
||||
}
|
||||
if (cardObj.dimEl && width && height) {
|
||||
cardObj.dimEl.textContent = `${width}x${height}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
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 +779,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();
|
||||
@@ -725,7 +892,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
@@ -756,7 +923,7 @@ function renderCardImages(cardObj, state, keepAspect) {
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
@@ -780,7 +947,7 @@ const handleGlobalClick = (e) => {
|
||||
}
|
||||
const sidebar = State.sidebarContainer;
|
||||
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"]');
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay');
|
||||
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
State.activeSubmenuPromptId = null;
|
||||
@@ -797,7 +964,6 @@ export function renderDOM() {
|
||||
if (renderTimeout) cancelAnimationFrame(renderTimeout);
|
||||
renderTimeout = requestAnimationFrame(() => {
|
||||
const showPendingSummary = app.ui.settings.getSettingValue("Comfy Sidebar.Show Pending Count Only") ?? true;
|
||||
const keepAspect = app.ui.settings.getSettingValue("Comfy Sidebar.Keep Object Aspect Ratio") ?? true;
|
||||
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
|
||||
const headerTitle = State.sidebarContainer.querySelector("h3");
|
||||
@@ -855,9 +1021,7 @@ export function renderDOM() {
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
@@ -893,9 +1057,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 });
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
|
||||
@@ -966,9 +1128,7 @@ export function renderDOM() {
|
||||
grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
@@ -1005,7 +1165,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 });
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "block";
|
||||
cardObj.placeholder.textContent = "No Outputs";
|
||||
@@ -1064,11 +1224,10 @@ export function renderDOM() {
|
||||
const sBadge = document.createElement("div");
|
||||
Object.assign(sBadge.style, { position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold", padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none", pointerEvents: "none", zIndex: "10" });
|
||||
const grid = document.createElement("div"); grid.style.display = "flex"; grid.style.flexDirection = "column"; grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
Object.assign(p.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px", userSelect: "none",
|
||||
display: "-webkit-box", WebkitLineClamp: "15", WebkitBoxOrient: "vertical", overflow: "hidden", wordBreak: "break-word"
|
||||
});
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const pt = document.createElement("div"); Object.assign(pt.style, { width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px", overflow: "hidden", display: "none" });
|
||||
const pb = document.createElement("div"); Object.assign(pb.style, { width: `0%`, height: "100%", background: "#3b82f6", transition: "width 0.1s linear" });
|
||||
pt.appendChild(pb);
|
||||
@@ -1200,22 +1359,37 @@ 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";
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, state, keepAspect);
|
||||
renderCardImages(cardObj, state);
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
|
||||
if (state.images.length === 0) {
|
||||
if (state.texts && state.texts.length > 0) {
|
||||
cardObj.placeholder.textContent = state.texts.join("\n"); Object.assign(cardObj.placeholder.style, { whiteSpace: "pre-wrap", textAlign: "left" });
|
||||
const fullText = state.texts.join("\n\n");
|
||||
cardObj.placeholder.textContent = fullText;
|
||||
cardObj.placeholder.title = "Click to read full text";
|
||||
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
|
||||
cardObj.placeholder.style.display = "-webkit-box";
|
||||
cardObj.placeholder.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
|
||||
};
|
||||
} else {
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs"; Object.assign(cardObj.placeholder.style, { whiteSpace: "normal", textAlign: "center" });
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs";
|
||||
cardObj.placeholder.className = "";
|
||||
Object.assign(cardObj.placeholder.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
|
||||
userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default"
|
||||
});
|
||||
cardObj.placeholder.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -14,6 +14,28 @@ export const is3DFormat = (url) => {
|
||||
s.includes(".spz") || s.includes(".ksplat") || s.includes(".fbx");
|
||||
};
|
||||
|
||||
export const isAudioFormat = (url) => {
|
||||
if (!url) return false;
|
||||
const s = String(url).toLowerCase();
|
||||
return s.includes(".wav") || s.includes(".mp3") || s.includes(".ogg") ||
|
||||
s.includes(".flac") || s.includes(".m4a") || s.includes(".aac") ||
|
||||
s.includes(".opus");
|
||||
};
|
||||
|
||||
export function getFilenameFromUrl(url) {
|
||||
if (!url) return "";
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
if (u.searchParams && u.searchParams.get("filename")) {
|
||||
return u.searchParams.get("filename");
|
||||
}
|
||||
const pathname = u.pathname || "";
|
||||
return pathname.split("/").pop() || "";
|
||||
} catch (e) {
|
||||
return String(url).split("?")[0].split("/").pop() || "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWorkflow(workflow) {
|
||||
if (!workflow) return null;
|
||||
if (typeof workflow === "object") return workflow;
|
||||
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
import { is3DFormat } from "./utils.js";
|
||||
import { State } from "./state.js";
|
||||
|
||||
let globalKeydownHandler = null;
|
||||
let cachedThreeLibs = null;
|
||||
|
||||
async function getThreeLibs() {
|
||||
if (cachedThreeLibs) return cachedThreeLibs;
|
||||
|
||||
if (window.THREE && window.THREE.GLTFLoader && window.THREE.OrbitControls) {
|
||||
cachedThreeLibs = window.THREE;
|
||||
return cachedThreeLibs;
|
||||
}
|
||||
|
||||
try {
|
||||
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
|
||||
import("https://esm.sh/three@0.170.0"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/GLTFLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/OBJLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/STLLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/PLYLoader.js")
|
||||
]);
|
||||
|
||||
cachedThreeLibs = {
|
||||
...three,
|
||||
OrbitControls: controls.OrbitControls,
|
||||
GLTFLoader: gltf.GLTFLoader,
|
||||
OBJLoader: obj.OBJLoader,
|
||||
STLLoader: stl.STLLoader,
|
||||
PLYLoader: ply.PLYLoader
|
||||
};
|
||||
return cachedThreeLibs;
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to load Three.js libraries", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-sidebar-comparison-overlay comfy-sidebar-3d-overlay-root";
|
||||
Object.assign(container.style, {
|
||||
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
|
||||
background: "rgba(10, 10, 10, 0.95)", display: "flex", flexDirection: "column",
|
||||
zIndex: "1000", boxSizing: "border-box", overflow: "hidden",
|
||||
pointerEvents: "auto", userSelect: "none", "-webkit-user-select": "none"
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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: "#aaa", fontSize: "12px",
|
||||
fontFamily: "monospace", pointerEvents: "none", background: "rgba(10,10,10,0.75)",
|
||||
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(4px)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
|
||||
});
|
||||
const titleText = document.createElement("span");
|
||||
titleText.textContent = baseSrc.split("/").pop().split("?")[0] || "3D Model";
|
||||
header.appendChild(titleText);
|
||||
container.appendChild(header);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "pi pi-times";
|
||||
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",
|
||||
background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px"
|
||||
});
|
||||
closeBtn.onmouseenter = () => closeBtn.style.color = "#fff";
|
||||
closeBtn.onmouseleave = () => closeBtn.style.color = "#aaa";
|
||||
container.appendChild(closeBtn);
|
||||
|
||||
const canvasContainer = document.createElement("div");
|
||||
Object.assign(canvasContainer.style, {
|
||||
width: "100%", height: "100%", position: "relative",
|
||||
display: "flex", alignItems: "center", justifyContent: "center"
|
||||
});
|
||||
container.appendChild(canvasContainer);
|
||||
|
||||
const loadingSpinner = document.createElement("div");
|
||||
Object.assign(loadingSpinner.style, {
|
||||
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
|
||||
color: "#60a5fa", fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
|
||||
background: "rgba(0,0,0,0.8)", padding: "10px 18px", borderRadius: "8px",
|
||||
display: "flex", alignItems: "center", gap: "10px"
|
||||
});
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
|
||||
container.appendChild(loadingSpinner);
|
||||
|
||||
const settingsPanel = document.createElement("div");
|
||||
Object.assign(settingsPanel.style, {
|
||||
position: "absolute", top: "60px", right: "20px", width: "230px",
|
||||
background: "rgba(18, 18, 24, 0.9)", backdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.12)", borderRadius: "8px",
|
||||
padding: "14px", zIndex: "35", color: "#e2e8f0", fontSize: "11px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", display: "none", flexDirection: "column", gap: "12px"
|
||||
});
|
||||
|
||||
const createFormGroup = (label, inputEl) => {
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = label;
|
||||
Object.assign(lbl.style, { color: "#94a3b8", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
|
||||
row.append(lbl, inputEl);
|
||||
return row;
|
||||
};
|
||||
|
||||
const createSelect = (options, value, onChange) => {
|
||||
const sel = document.createElement("select");
|
||||
Object.assign(sel.style, {
|
||||
background: "#252530", color: "#eee", border: "1px solid #444",
|
||||
borderRadius: "4px", padding: "4px 6px", fontSize: "11px", outline: "none"
|
||||
});
|
||||
options.forEach(opt => {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.label;
|
||||
if (opt.value === value) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = (e) => onChange(e.target.value);
|
||||
return sel;
|
||||
};
|
||||
|
||||
const toolbar = document.createElement("div");
|
||||
Object.assign(toolbar.style, {
|
||||
position: "absolute", bottom: "20px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", gap: "8px", zIndex: "30", background: "rgba(20,20,25,0.85)",
|
||||
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(6px)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.1)",
|
||||
alignItems: "center"
|
||||
});
|
||||
|
||||
const createToolbarBtn = (iconClass, label, activeDefault, onClick) => {
|
||||
const btn = document.createElement("button");
|
||||
let active = activeDefault;
|
||||
btn.className = "comfy-sidebar-3d-tool-btn";
|
||||
Object.assign(btn.style, {
|
||||
background: active ? "#3b82f6" : "transparent", color: active ? "#fff" : "#aaa",
|
||||
border: "1px solid " + (active ? "#3b82f6" : "#444"), borderRadius: "4px",
|
||||
padding: "4px 10px", fontSize: "11px", cursor: "pointer", display: "flex",
|
||||
alignItems: "center", gap: "6px", transition: "all 0.15s ease"
|
||||
});
|
||||
btn.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
active = !active;
|
||||
btn.style.background = active ? "#3b82f6" : "transparent";
|
||||
btn.style.color = active ? "#fff" : "#aaa";
|
||||
btn.style.borderColor = active ? "#3b82f6" : "#444";
|
||||
onClick(active);
|
||||
};
|
||||
return btn;
|
||||
};
|
||||
|
||||
let renderer, scene, camera, cameraPersp, cameraOrtho, controls, currentModel, gridHelper, animId;
|
||||
let ambientLight, dirLight1, dirLight2;
|
||||
let autoRotate = false, showWireframe = false, currentMaterialMode = "original", currentUpDirection = "y-up";
|
||||
let lightMultiplier = 1.0;
|
||||
let THREE = null;
|
||||
|
||||
const applyMaterialMode = () => {
|
||||
if (!currentModel || !THREE) return;
|
||||
currentModel.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
if (currentMaterialMode === "clay") {
|
||||
child.material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.6, metalness: 0.1 });
|
||||
} else if (currentMaterialMode === "normal") {
|
||||
child.material = new THREE.MeshNormalMaterial();
|
||||
} else {
|
||||
child.material = child._originalMaterial || child.material;
|
||||
}
|
||||
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(m => m.wireframe = showWireframe);
|
||||
} else if (child.material) {
|
||||
child.material.wireframe = showWireframe;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyUpDirection = () => {
|
||||
if (!currentModel || !THREE) return;
|
||||
if (currentUpDirection === "z-up") {
|
||||
currentModel.rotation.x = -Math.PI / 2;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
} else if (currentUpDirection === "z-down") {
|
||||
currentModel.rotation.x = Math.PI / 2;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
} else {
|
||||
currentModel.rotation.x = 0;
|
||||
currentModel.rotation.y = 0;
|
||||
currentModel.rotation.z = 0;
|
||||
}
|
||||
if (controls && camera) {
|
||||
fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
if (gridHelper) gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBlob = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportModel = async (format) => {
|
||||
if (!currentModel) return;
|
||||
const baseName = (titleText.textContent || "model").replace(/\.[^/.]+$/, "");
|
||||
|
||||
try {
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
|
||||
|
||||
if (format === "OBJ") {
|
||||
const { OBJExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/OBJExporter.js");
|
||||
const exporter = new OBJExporter();
|
||||
const result = exporter.parse(currentModel);
|
||||
const blob = new Blob([result], { type: "text/plain" });
|
||||
downloadBlob(blob, `${baseName}.obj`);
|
||||
} else if (format === "GLB") {
|
||||
const { GLTFExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/GLTFExporter.js");
|
||||
const exporter = new GLTFExporter();
|
||||
exporter.parse(currentModel, (gltf) => {
|
||||
const blob = new Blob([gltf], { type: "application/octet-stream" });
|
||||
downloadBlob(blob, `${baseName}.glb`);
|
||||
}, (err) => { console.error(err); }, { binary: true });
|
||||
} else if (format === "STL") {
|
||||
const { STLExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/STLExporter.js");
|
||||
const exporter = new STLExporter();
|
||||
const result = exporter.parse(currentModel, { binary: true });
|
||||
const blob = new Blob([result], { type: "application/octet-stream" });
|
||||
downloadBlob(blob, `${baseName}.stl`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Export failed", err);
|
||||
} finally {
|
||||
loadingSpinner.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup3D = () => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
if (controls) controls.dispose();
|
||||
if (currentModel && scene) scene.remove(currentModel);
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
window.removeEventListener("resize", updateOverlayBounds);
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (globalKeydownHandler) {
|
||||
document.removeEventListener("keydown", globalKeydownHandler);
|
||||
globalKeydownHandler = null;
|
||||
}
|
||||
cleanup3D();
|
||||
container.remove();
|
||||
onDestroy();
|
||||
};
|
||||
|
||||
closeBtn.onclick = destroy;
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container || e.target === canvasContainer) destroy();
|
||||
};
|
||||
|
||||
const handleKeys = (e) => {
|
||||
if (e.key === "Escape") destroy();
|
||||
};
|
||||
globalKeydownHandler = handleKeys;
|
||||
document.addEventListener("keydown", globalKeydownHandler);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
const initSceneAndLoad = async (srcUrl) => {
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
|
||||
titleText.textContent = srcUrl.split("/").pop().split("?")[0] || "3D Model";
|
||||
|
||||
THREE = await getThreeLibs();
|
||||
if (!THREE) {
|
||||
loadingSpinner.textContent = "Failed to load 3D engine.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0e0e12);
|
||||
|
||||
const aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
|
||||
cameraPersp = new THREE.PerspectiveCamera(45, aspect, 0.01, 1000);
|
||||
cameraPersp.position.set(0, 1.5, 3.5);
|
||||
|
||||
const d = 2;
|
||||
cameraOrtho = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.01, 1000);
|
||||
cameraOrtho.position.set(0, 1.5, 3.5);
|
||||
|
||||
camera = cameraPersp;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
canvasContainer.appendChild(renderer.domElement);
|
||||
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
|
||||
ambientLight = new THREE.AmbientLight(0xffffff, 1.2 * lightMultiplier);
|
||||
scene.add(ambientLight);
|
||||
|
||||
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.8 * lightMultiplier);
|
||||
dirLight1.position.set(5, 10, 7);
|
||||
scene.add(dirLight1);
|
||||
|
||||
dirLight2 = new THREE.DirectionalLight(0x90b0ff, 0.8 * lightMultiplier);
|
||||
dirLight2.position.set(-5, -5, -5);
|
||||
scene.add(dirLight2);
|
||||
|
||||
gridHelper = new THREE.GridHelper(10, 20, 0x3b82f6, 0x22222a);
|
||||
gridHelper.position.y = 0;
|
||||
scene.add(gridHelper);
|
||||
|
||||
const bgColorInput = document.createElement("input");
|
||||
bgColorInput.type = "color";
|
||||
bgColorInput.value = "#0e0e12";
|
||||
Object.assign(bgColorInput.style, { width: "100%", height: "26px", borderRadius: "4px", border: "none", cursor: "pointer" });
|
||||
bgColorInput.oninput = (e) => { scene.background.set(e.target.value); };
|
||||
|
||||
const upDirSelect = createSelect([
|
||||
{ value: "y-up", label: "Original (Y-Up)" },
|
||||
{ value: "z-up", label: "Z-Up (+90°)" },
|
||||
{ value: "z-down", label: "Inverted (-90°)" }
|
||||
], currentUpDirection, (val) => {
|
||||
currentUpDirection = val;
|
||||
applyUpDirection();
|
||||
});
|
||||
|
||||
const matModeSelect = createSelect([
|
||||
{ value: "original", label: "Original / Textured" },
|
||||
{ value: "clay", label: "Clay / Studio Matte" },
|
||||
{ value: "normal", label: "Normals" }
|
||||
], currentMaterialMode, (val) => {
|
||||
currentMaterialMode = val;
|
||||
applyMaterialMode();
|
||||
});
|
||||
|
||||
const camTypeSelect = createSelect([
|
||||
{ value: "perspective", label: "Perspective" },
|
||||
{ value: "orthographic", label: "Orthographic" }
|
||||
], "perspective", (val) => {
|
||||
const prevPos = camera.position.clone();
|
||||
const prevTarget = controls.target.clone();
|
||||
|
||||
if (val === "orthographic") {
|
||||
camera = cameraOrtho;
|
||||
} else {
|
||||
camera = cameraPersp;
|
||||
}
|
||||
camera.position.copy(prevPos);
|
||||
controls.object = camera;
|
||||
controls.target.copy(prevTarget);
|
||||
controls.update();
|
||||
});
|
||||
|
||||
const fovContainer = document.createElement("div");
|
||||
Object.assign(fovContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
const fovSlider = document.createElement("input");
|
||||
Object.assign(fovSlider, { type: "range", min: "15", max: "120", value: "45" });
|
||||
fovSlider.style.flex = "1";
|
||||
const fovVal = document.createElement("span");
|
||||
fovVal.textContent = "45°";
|
||||
fovSlider.oninput = (e) => {
|
||||
const v = Number(e.target.value);
|
||||
fovVal.textContent = `${v}°`;
|
||||
cameraPersp.fov = v;
|
||||
cameraPersp.updateProjectionMatrix();
|
||||
};
|
||||
fovContainer.append(fovSlider, fovVal);
|
||||
|
||||
const lightContainer = document.createElement("div");
|
||||
Object.assign(lightContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
const lightSlider = document.createElement("input");
|
||||
Object.assign(lightSlider, { type: "range", min: "0.1", max: "3.0", step: "0.1", value: "1.0" });
|
||||
lightSlider.style.flex = "1";
|
||||
const lightVal = document.createElement("span");
|
||||
lightVal.textContent = "1.0x";
|
||||
lightSlider.oninput = (e) => {
|
||||
lightMultiplier = Number(e.target.value);
|
||||
lightVal.textContent = `${lightMultiplier.toFixed(1)}x`;
|
||||
ambientLight.intensity = 1.2 * lightMultiplier;
|
||||
dirLight1.intensity = 1.8 * lightMultiplier;
|
||||
dirLight2.intensity = 0.8 * lightMultiplier;
|
||||
};
|
||||
lightContainer.append(lightSlider, lightVal);
|
||||
|
||||
settingsPanel.append(
|
||||
createFormGroup("Background Color", bgColorInput),
|
||||
createFormGroup("Up Direction", upDirSelect),
|
||||
createFormGroup("Material Mode", matModeSelect),
|
||||
createFormGroup("Camera Type", camTypeSelect),
|
||||
createFormGroup("FOV", fovContainer),
|
||||
createFormGroup("Light Intensity", lightContainer)
|
||||
);
|
||||
container.appendChild(settingsPanel);
|
||||
|
||||
const btnGrid = createToolbarBtn("pi pi-table", "Grid", true, (v) => { gridHelper.visible = v; });
|
||||
const btnRotate = createToolbarBtn("pi pi-sync", "Auto-Rotate", false, (v) => { autoRotate = v; });
|
||||
const btnWire = createToolbarBtn("pi pi-box", "Wireframe", false, (v) => {
|
||||
showWireframe = v;
|
||||
applyMaterialMode();
|
||||
});
|
||||
|
||||
const btnResetCam = document.createElement("button");
|
||||
Object.assign(btnResetCam.style, {
|
||||
background: "transparent", color: "#aaa", border: "1px solid #444",
|
||||
borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", gap: "6px"
|
||||
});
|
||||
btnResetCam.innerHTML = `<i class="pi pi-compass"></i><span>Reset View</span>`;
|
||||
btnResetCam.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (currentModel) fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
};
|
||||
|
||||
const btnSettings = createToolbarBtn("pi pi-sliders-h", "Settings", false, (v) => {
|
||||
settingsPanel.style.display = v ? "flex" : "none";
|
||||
});
|
||||
|
||||
const exportSelect = createSelect([
|
||||
{ value: "", label: "Export As..." },
|
||||
{ value: "OBJ", label: "OBJ (.obj)" },
|
||||
{ value: "GLB", label: "GLB / GLTF" },
|
||||
{ value: "STL", label: "STL (.stl)" }
|
||||
], "", (val) => {
|
||||
if (val) {
|
||||
exportModel(val);
|
||||
exportSelect.value = "";
|
||||
}
|
||||
});
|
||||
exportSelect.style.background = "#1e293b";
|
||||
exportSelect.style.borderColor = "#3b82f6";
|
||||
exportSelect.style.color = "#93c5fd";
|
||||
|
||||
toolbar.append(btnGrid, btnWire, btnRotate, btnResetCam, btnSettings, exportSelect);
|
||||
container.appendChild(toolbar);
|
||||
|
||||
const onResize = () => {
|
||||
if (!canvasContainer || !renderer || !cameraPersp || !cameraOrtho) return;
|
||||
const width = canvasContainer.clientWidth;
|
||||
const height = canvasContainer.clientHeight;
|
||||
const aspect = width / height;
|
||||
|
||||
cameraPersp.aspect = aspect;
|
||||
cameraPersp.updateProjectionMatrix();
|
||||
|
||||
const d = 2;
|
||||
cameraOrtho.left = -d * aspect;
|
||||
cameraOrtho.right = d * aspect;
|
||||
cameraOrtho.top = d;
|
||||
cameraOrtho.bottom = -d;
|
||||
cameraOrtho.updateProjectionMatrix();
|
||||
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
if (autoRotate && currentModel) currentModel.rotation.y += 0.008;
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
}
|
||||
|
||||
if (currentModel) {
|
||||
scene.remove(currentModel);
|
||||
currentModel = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ext = (srcUrl.split("?")[0].split(".").pop() || "glb").toLowerCase();
|
||||
let loadedObj = null;
|
||||
|
||||
if (ext === "obj" && THREE.OBJLoader) {
|
||||
const loader = new THREE.OBJLoader();
|
||||
loadedObj = await loader.loadAsync(srcUrl);
|
||||
} else if (ext === "stl" && THREE.STLLoader) {
|
||||
const loader = new THREE.STLLoader();
|
||||
const geometry = await loader.loadAsync(srcUrl);
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4, metalness: 0.2 });
|
||||
loadedObj = new THREE.Mesh(geometry, material);
|
||||
} else if (ext === "ply" && THREE.PLYLoader) {
|
||||
const loader = new THREE.PLYLoader();
|
||||
const geometry = await loader.loadAsync(srcUrl);
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x90caf9, roughness: 0.4 });
|
||||
loadedObj = new THREE.Mesh(geometry, material);
|
||||
} else if (THREE.GLTFLoader) {
|
||||
const loader = new THREE.GLTFLoader();
|
||||
const gltfData = await loader.loadAsync(srcUrl);
|
||||
loadedObj = gltfData.scene || gltfData.scenes?.[0];
|
||||
}
|
||||
|
||||
if (loadedObj) {
|
||||
currentModel = loadedObj;
|
||||
|
||||
currentModel.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
child._originalMaterial = child.material;
|
||||
}
|
||||
});
|
||||
|
||||
applyMaterialMode();
|
||||
applyUpDirection();
|
||||
|
||||
scene.add(currentModel);
|
||||
fitCameraToObject(camera, currentModel, controls, THREE);
|
||||
gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to parse 3D model", err);
|
||||
loadingSpinner.innerHTML = `<span style="color:#f87171;">Failed to load 3D format</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingSpinner.style.display = "none";
|
||||
};
|
||||
|
||||
function fitCameraToObject(cam, obj, ctrl, THREE) {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
|
||||
if (cam.isPerspectiveCamera) {
|
||||
const fov = cam.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
|
||||
cam.position.set(center.x, center.y + size.y * 0.2, center.z + cameraZ);
|
||||
} else if (cam.isOrthographicCamera) {
|
||||
cam.position.set(center.x, center.y + size.y * 0.2, center.z + maxDim * 2);
|
||||
}
|
||||
|
||||
cam.lookAt(center);
|
||||
ctrl.target.copy(center);
|
||||
ctrl.update();
|
||||
}
|
||||
|
||||
initSceneAndLoad(baseSrc);
|
||||
|
||||
return {
|
||||
is3D: true,
|
||||
loadTarget(targetSrc) {
|
||||
if (!is3DFormat(targetSrc)) {
|
||||
destroy();
|
||||
onSwitchMedia(targetSrc);
|
||||
return;
|
||||
}
|
||||
initSceneAndLoad(targetSrc);
|
||||
},
|
||||
destroy
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfy-sidebar"
|
||||
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user