Refactor. Improve light theme compatibility. Add offline 3D viewer & CSP support. Improve file deletion logic

This commit is contained in:
m0rtus59
2026-09-10 21:57:14 +05:00
parent 3e7448dd80
commit 8343f216fc
29 changed files with 4086 additions and 3122 deletions
+754
View File
@@ -0,0 +1,754 @@
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js";
import { create3DViewer } from "./viewer3d.js";
import { createTextReader } from "./text_reader.js";
import { stopAllAudioPlayback } from "../ui/audioController.js";
import { SidebarOverlay } from "./overlay.js";
let activeComparisonViewer = null;
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 = () => {}) {
let togglePlayFn = () => {};
const overlay = new SidebarOverlay({
onDestroy,
onKeyDown: (e) => {
if (e.key === " ") {
e.preventDefault();
togglePlayFn();
}
}
});
const playerBox = document.createElement("div");
Object.assign(playerBox.style, {
background: "linear-gradient(135deg, #1e1333 0%, #0f172a 100%)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "12px",
padding: "24px 28px", width: "90%", maxWidth: "520px",
boxShadow: "0 8px 32px rgba(0,0,0,0.7)", display: "flex",
flexDirection: "column", gap: "16px", zIndex: "20"
});
const parsedFilename = getFilenameFromUrl(baseSrc) || "audio.wav";
const parsedExt = (parsedFilename.split(".").pop() || "AUDIO").toUpperCase();
const titleRow = document.createElement("div");
Object.assign(titleRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between" });
const title = document.createElement("span");
title.textContent = parsedFilename;
Object.assign(title.style, { fontSize: "14px", fontWeight: "bold", color: "#f1f5f9", fontFamily: "monospace", maxWidth: "75%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
const badge = document.createElement("span");
badge.className = "comfy-sidebar-audio-badge";
badge.style.position = "static";
badge.textContent = parsedExt;
titleRow.append(title, badge);
const soundwave = document.createElement("div");
soundwave.className = "comfy-sidebar-soundwave-container";
soundwave.style.height = "56px";
const barHeights = [14, 22, 38, 28, 48, 36, 20, 42, 54, 32, 18, 40, 50, 30, 22, 36, 48, 24, 18, 34, 46, 26, 16];
barHeights.forEach((h, i) => {
const bar = document.createElement("div");
bar.className = "comfy-sidebar-soundwave-bar";
bar.style.height = `${h}px`;
bar.style.width = "4px";
bar.style.animationDelay = `${(i * 0.06).toFixed(2)}s`;
soundwave.appendChild(bar);
});
const scrubberContainer = document.createElement("div");
Object.assign(scrubberContainer.style, {
width: "100%", height: "6px", background: "#334155", borderRadius: "3px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "3px" });
scrubberContainer.appendChild(scrubberFill);
const controlsRow = document.createElement("div");
Object.assign(controlsRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
const leftControls = document.createElement("div");
Object.assign(leftControls.style, { display: "flex", alignItems: "center", gap: "10px" });
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
const playBtn = document.createElement("button");
Object.assign(playBtn.style, {
width: "36px", height: "36px", borderRadius: "50%", background: "#a855f7",
border: "none", color: "#fff", cursor: "pointer", display: "flex",
alignItems: "center", justifyContent: "center", fontSize: "14px"
});
playBtn.innerHTML = playIconSvg;
const timeLabel = document.createElement("span");
Object.assign(timeLabel.style, { fontSize: "11px", fontFamily: "monospace", color: "#94a3b8" });
timeLabel.textContent = "0:00 / 0:00";
leftControls.append(playBtn, timeLabel);
const rightControls = document.createElement("div");
Object.assign(rightControls.style, { display: "flex", alignItems: "center", gap: "8px" });
const loopBtn = document.createElement("button");
Object.assign(loopBtn.style, {
background: "transparent", border: "1px solid #444", color: "#aaa",
borderRadius: "4px", padding: "4px 8px", fontSize: "11px", cursor: "pointer"
});
loopBtn.innerHTML = `<i class="pi pi-replay"></i>`;
loopBtn.title = "Toggle Loop";
const speedSelect = document.createElement("select");
Object.assign(speedSelect.style, {
background: "#1e293b", border: "1px solid #444", color: "#eee",
borderRadius: "4px", padding: "2px 4px", fontSize: "11px", outline: "none"
});
["0.5x", "1.0x", "1.5x", "2.0x"].forEach(spd => {
const opt = document.createElement("option");
opt.value = spd.replace("x", "");
opt.textContent = spd;
if (spd === "1.0x") opt.selected = true;
speedSelect.appendChild(opt);
});
rightControls.append(loopBtn, speedSelect);
controlsRow.append(leftControls, rightControls);
playerBox.append(titleRow, soundwave, scrubberContainer, controlsRow);
overlay.container.appendChild(playerBox);
const audioEl = document.createElement("audio");
audioEl.src = baseSrc;
audioEl.preload = "metadata";
overlay.container.appendChild(audioEl);
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
audioEl.onloadedmetadata = () => {
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
};
audioEl.ontimeupdate = () => {
if (audioEl.duration > 0) {
const percent = (audioEl.currentTime / audioEl.duration) * 100;
scrubberFill.style.width = `${percent}%`;
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
}
};
audioEl.onended = () => {
playBtn.innerHTML = playIconSvg;
playerBox.classList.remove("playing");
scrubberFill.style.width = "0%";
};
togglePlayFn = () => {
if (audioEl.paused) {
stopAllAudioPlayback();
audioEl.play().catch(()=>{});
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
} else {
audioEl.pause();
audioEl.currentTime = 0;
playBtn.innerHTML = playIconSvg;
playerBox.classList.remove("playing");
scrubberFill.style.width = "0%";
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
}
};
playBtn.onclick = togglePlayFn;
loopBtn.onclick = () => {
audioEl.loop = !audioEl.loop;
loopBtn.style.borderColor = audioEl.loop ? "#a855f7" : "#444";
loopBtn.style.color = audioEl.loop ? "#c084fc" : "#aaa";
};
speedSelect.onchange = (e) => { audioEl.playbackRate = Number(e.target.value); };
scrubberContainer.onclick = (e) => {
const rect = scrubberContainer.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
};
overlay.addCleanup(() => {
audioEl.pause();
audioEl.src = "";
});
audioEl.play().then(() => {
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
}).catch(()=>{});
return {
isAudio: true,
loadTarget(targetSrc) {
if (!isAudioFormat(targetSrc)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
const newFilename = getFilenameFromUrl(targetSrc) || "audio.wav";
title.textContent = newFilename;
badge.textContent = (newFilename.split(".").pop() || "AUDIO").toUpperCase();
audioEl.src = targetSrc;
audioEl.play().then(() => {
playBtn.innerHTML = stopIconSvg;
playerBox.classList.add("playing");
}).catch(()=>{});
},
destroy: () => overlay.destroy()
};
}
const createMediaElement = (src, muted = false) => {
const isVideo = isVideoFormat(src);
const el = isVideo ? document.createElement("video") : document.createElement("img");
if (isVideo) {
el.muted = muted;
el.playsInline = true;
el.autoplay = true;
el.loop = true;
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1",
maxWidth: "calc(100vw - 120px)",
maxHeight: "calc(100vh - 120px)",
width: "auto",
height: "auto",
objectFit: "contain",
pointerEvents: isVideo ? "none" : "auto",
userSelect: "none",
webkitUserSelect: "none",
display: "block"
});
el.draggable = false;
el.ondragstart = (e) => e.preventDefault();
el.src = isVideo ? src + "#t=0.001" : src;
return el;
};
const setupVideoPlayback = (vid, container) => {
if (!vid) return () => {};
let videoSyncActive = true;
let syncAnimationFrameId = null;
const onMetaLoaded = () => { vid.play().catch(()=>{}); };
vid.addEventListener("loadedmetadata", onMetaLoaded);
if (vid.readyState >= 1) onMetaLoaded();
const controlBar = document.createElement("div");
Object.assign(controlBar.style, {
position: "absolute", bottom: "16px", left: "50%",
display: "flex", alignItems: "center", gap: "12px",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.9))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
fontFamily: "monospace",
color: "var(--fg-color, #eee)",
width: "80%", maxWidth: "500px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)",
pointerEvents: "auto"
});
const playBtn = document.createElement("span");
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
playBtn.style.cursor = "pointer";
playBtn.style.fontSize = "14px";
playBtn.onclick = (e) => {
e.stopPropagation();
if (vid.paused) {
vid.play().catch(()=>{});
playBtn.className = "pi pi-pause";
} else {
vid.pause();
playBtn.className = "pi pi-play";
}
};
const scrubberContainer = document.createElement("div");
Object.assign(scrubberContainer.style, {
flex: "1", height: "4px", background: "#444", borderRadius: "2px",
position: "relative", cursor: "pointer"
});
const scrubberFill = document.createElement("div");
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#3b82f6", borderRadius: "2px" });
scrubberContainer.appendChild(scrubberFill);
const timeLabel = document.createElement("span");
timeLabel.textContent = "0:00 / 0:00";
const scrub = (e) => {
const rect = scrubberContainer.getBoundingClientRect();
const clientX = getClientX(e);
const percent = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width));
const duration = vid.duration || 0;
if (duration > 0) {
vid.currentTime = (percent / 100) * duration;
scrubberFill.style.width = `${percent}%`;
}
};
let isScrubbing = false;
scrubberContainer.onmousedown = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
scrubberContainer.ontouchstart = (e) => { e.stopPropagation(); isScrubbing = true; scrub(e); };
const handleWindowMove = (e) => { if (isScrubbing) scrub(e); };
const handleWindowUp = () => { isScrubbing = false; };
window.addEventListener("mousemove", handleWindowMove);
window.addEventListener("touchmove", handleWindowMove);
window.addEventListener("mouseup", handleWindowUp);
window.addEventListener("touchend", handleWindowUp);
controlBar.append(playBtn, scrubberContainer, timeLabel);
container.appendChild(controlBar);
const syncLoop = () => {
if (!videoSyncActive) return;
const cur = vid.currentTime || 0;
const dur = vid.duration || 0;
if (dur > 0) {
scrubberFill.style.width = `${(cur / dur) * 100}%`;
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
timeLabel.textContent = `${formatTime(cur)} / ${formatTime(dur)}`;
}
playBtn.className = vid.paused ? "pi pi-play" : "pi pi-pause";
syncAnimationFrameId = requestAnimationFrame(syncLoop);
};
requestAnimationFrame(syncLoop);
return () => {
videoSyncActive = false;
if (syncAnimationFrameId) cancelAnimationFrame(syncAnimationFrameId);
window.removeEventListener("mousemove", handleWindowMove);
window.removeEventListener("touchmove", handleWindowMove);
window.removeEventListener("mouseup", handleWindowUp);
window.removeEventListener("touchend", handleWindowUp);
vid.removeEventListener("loadedmetadata", onMetaLoaded);
controlBar.remove();
};
};
function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const isBaseVideo = isVideoFormat(baseSrc);
let splitRatio = 50;
let mediaB = null;
const overlay = new SidebarOverlay({
onDestroy,
onKeyDown: (e) => {
if (e.key === " " && mediaB) {
e.preventDefault();
updateSliderPosition(splitRatio > 50 ? 0 : 100);
}
}
});
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: "48px 24px", scrollbarWidth: "thin",
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
});
overlay.container.appendChild(scrollContainer);
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30",
color: "var(--desc-color, #aaa)",
fontSize: "12px",
fontFamily: "sans-serif", pointerEvents: "none",
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.85))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(6px)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center"
});
const infoText = document.createElement("span");
infoText.textContent = isBaseVideo
? "Video playback. Press Esc to close."
: "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
header.appendChild(infoText);
overlay.container.appendChild(header);
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
margin: "auto", flexShrink: "0",
cursor: isBaseVideo ? "default" : "zoom-in"
});
scrollContainer.appendChild(wrapper);
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
let isZoomed = false;
const slider = document.createElement("div");
Object.assign(slider.style, {
position: "absolute", top: "0", bottom: "0", left: "50%",
width: "2px", background: "#fff", cursor: "ew-resize",
zIndex: "25", display: "none", pointerEvents: "auto",
boxShadow: "0 0 8px rgba(0,0,0,0.5)"
});
const sliderButton = document.createElement("div");
Object.assign(sliderButton.style, {
position: "absolute", top: "50%", left: "50%",
transform: "translate(-50%, -50%)", width: "32px", height: "32px",
borderRadius: "50%", background: "#fff", color: "#333",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", pointerEvents: "auto"
});
sliderButton.innerHTML = `<span class="pi pi-arrows-h" style="font-size: 12px;"></span>`;
slider.appendChild(sliderButton);
wrapper.appendChild(slider);
const updateSliderPosition = (percent) => {
splitRatio = Math.max(0, Math.min(100, percent));
slider.style.left = `${splitRatio}%`;
if (mediaB) {
mediaB.style.clipPath = `polygon(${splitRatio}% 0, 100% 0, 100% 100%, ${splitRatio}% 100%)`;
}
};
const cleanupMedia = (el) => {
if (el && el.tagName === "VIDEO") {
el.pause();
el.src = "";
el.load();
}
};
const syncImageScales = () => {
if (!mediaA) return;
const wA = mediaA.naturalWidth || mediaA.videoWidth || 0;
const hA = mediaA.naturalHeight || mediaA.videoHeight || 0;
if (!mediaB) {
slider.style.display = "none";
if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto";
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 = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
mediaA.style.maxWidth = "calc(100vw - 120px)";
mediaA.style.maxHeight = "calc(100vh - 120px)";
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";
if (mediaA) mediaA.style.pointerEvents = "none";
if (mediaB) mediaB.style.pointerEvents = "none";
infoText.style.color = "#aaa";
infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card to compare | 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 = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = "zoom-in";
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "calc(100vw - 120px)";
el.style.maxHeight = "calc(100vh - 120px)";
el.style.width = "auto";
el.style.height = "auto";
el.style.objectFit = "contain";
}
});
}
};
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, dragMoved = false, dragStartX = 0, dragStartY = 0;
const startDrag = (e) => {
if (e.button && e.button !== 0) return;
dragStartX = getClientX(e);
dragStartY = getClientY(e);
dragMoved = false;
isDraggingSlider = true;
};
const doDrag = (e) => {
if (!isDraggingSlider) return;
const clientX = getClientX(e);
const clientY = getClientY(e);
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 = () => {
if (!isDraggingSlider) return;
isDraggingSlider = false;
if (dragMoved) setTimeout(() => { dragMoved = false; }, 50);
};
wrapper.addEventListener("mousedown", startDrag);
wrapper.addEventListener("touchstart", startDrag);
window.addEventListener("mousemove", doDrag);
window.addEventListener("touchmove", doDrag);
window.addEventListener("mouseup", endDrag);
window.addEventListener("touchend", endDrag);
overlay.addCleanup(() => {
window.removeEventListener("mousemove", doDrag);
window.removeEventListener("touchmove", doDrag);
window.removeEventListener("mouseup", endDrag);
window.removeEventListener("touchend", endDrag);
});
wrapper.addEventListener("click", () => {
if (dragMoved || isBaseVideo) return;
toggleZoom();
});
scrollContainer.addEventListener("click", (e) => {
if (dragMoved) return;
if (e.target === scrollContainer || e.target === overlay.container) overlay.destroy();
});
if (isBaseVideo) {
const destroyVideoPlayback = setupVideoPlayback(mediaA, overlay.container);
overlay.addCleanup(destroyVideoPlayback);
}
overlay.addCleanup(() => {
cleanupMedia(mediaA);
cleanupMedia(mediaB);
});
return {
is3D: false,
isAudio: false,
isText: false,
loadTarget(targetSrc, isShiftClick) {
const isTargetVideo = isVideoFormat(targetSrc);
const isTarget3D = is3DFormat(targetSrc);
const isTargetAudio = isAudioFormat(targetSrc);
const isTargetText = typeof targetSrc === "object" && targetSrc.text;
if (isBaseVideo || isTargetVideo || isTarget3D || isTargetAudio || isTargetText) {
overlay.destroy();
showFullscreenPreview([targetSrc], isShiftClick);
return;
}
if (isShiftClick) {
if (mediaB) {
cleanupMedia(mediaB);
mediaB.remove();
mediaB = null;
}
mediaB = createMediaElement(targetSrc, false);
mediaB.style.pointerEvents = "none";
mediaB.onload = syncImageScales;
wrapper.appendChild(mediaB);
if (mediaB.complete && mediaB.naturalWidth) syncImageScales();
} else {
if (mediaB) {
cleanupMedia(mediaB);
mediaB.remove();
mediaB = null;
}
slider.style.display = "none";
mediaA.src = targetSrc;
mediaA.onload = () => {
infoText.style.color = "#aaa";
infoText.textContent = "Click image to zoom (100%/Fit) | Shift+Click another card to compare.";
syncImageScales();
};
}
},
destroy: () => overlay.destroy()
};
}
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
if (!imgSrcs || imgSrcs.length === 0) return;
const item = imgSrcs[0];
// 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) {
if (activeComparisonViewer.is3D || activeComparisonViewer.isAudio || activeComparisonViewer.isText) {
activeComparisonViewer.destroy();
activeComparisonViewer = null;
} else {
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
}
activeComparisonViewer = createComparisonViewer(
src,
() => { activeComparisonViewer = null; }
);
}
+185
View File
@@ -0,0 +1,185 @@
import { app } from "/scripts/app.js";
import { store } from "../core/store.js";
async function promoteOrUploadToInput(imageObj) {
// 1. Try server-side promotion first (no browser RAM/re-upload overhead)
try {
const promoteRes = await fetch("/comfy-sidebar/promote-output", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(imageObj)
});
if (promoteRes.ok) {
const data = await promoteRes.json();
if (data.name) return data.name;
}
} catch (e) {}
// 2. Fallback to browser blob pipeline if backend promotion endpoint is unavailable
const src = `/view?filename=${encodeURIComponent(imageObj.filename)}&type=${imageObj.type || 'output'}&subfolder=${encodeURIComponent(imageObj.subfolder || '')}`;
try {
const response = await fetch(src);
if (!response.ok) return null;
const blob = await response.blob();
const file = new File([blob], imageObj.filename || "dropped_file", { type: blob.type });
const formData = new FormData();
formData.append("image", file);
formData.append("overwrite", "true");
formData.append("type", "input");
const uploadRes = await fetch("/upload/image", { method: "POST", body: formData });
if (!uploadRes.ok) return null;
const uploadData = await uploadRes.json();
return uploadData.name;
} catch(e) {
console.error("Comfy Sidebar: Failed to copy file to input.", e);
return null;
}
}
async function handleWorkflowDrop(e) {
const jsonStr = e.dataTransfer.getData("application/json");
if (!jsonStr) return false;
try {
const workflow = JSON.parse(jsonStr);
if (workflow && workflow.nodes) {
if (app.loadGraphData) {
app.loadGraphData(workflow);
} else if (app.handleFile) {
const file = new File([jsonStr], "workflow.json", { type: "application/json" });
await app.handleFile(file);
}
return true;
}
} catch (err) {}
return false;
}
async function handleMediaDrop(e) {
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
if (!url) return false;
try {
const urlObj = new URL(url, window.location.origin);
if (urlObj.pathname !== "/view") return false;
const filename = urlObj.searchParams.get("filename");
const type = urlObj.searchParams.get("type") || "output";
const subfolder = urlObj.searchParams.get("subfolder") || "";
if (!filename) return false;
const canvas = app.canvas;
if (!canvas || !canvas.graph) return false;
let targetNode = null;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
targetNode = canvas.graph.getNodeOnPos(pos[0], pos[1]);
} else if (canvas.canvas) {
const rect = canvas.canvas.getBoundingClientRect();
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);
}
const widget = targetNode?.widgets?.find(w =>
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 && (
widget ||
targetNode.type?.includes("LoadImage") ||
targetNode.type?.includes("LoadVideo") ||
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")
);
if (isLoadNode) {
const targetWidget = widget || targetNode.widgets?.[0];
if (targetWidget) {
const newFilename = await promoteOrUploadToInput({ filename, type, subfolder });
if (newFilename) {
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
if (!targetWidget.options.values.includes(newFilename)) {
targetWidget.options.values.push(newFilename);
}
}
targetWidget.value = newFilename;
if (targetWidget.callback) targetWidget.callback(targetWidget.value);
targetNode.imgs = null;
app.graph.setDirtyCanvas(true, true);
return true;
}
}
} else {
const src = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
const res = await fetch(src);
if (res.ok) {
const blob = await res.blob();
const file = new File([blob], filename, { type: blob.type });
if (app.handleFile) await app.handleFile(file);
else if (app.canvas?.handleDropItem) app.canvas.handleDropItem({ getAsFile: () => file });
return true;
}
}
} catch (err) {
console.error("Comfy Sidebar: Failed to handle dropped native URL:", err);
}
return false;
}
const dropHandlers = [
handleWorkflowDrop,
handleMediaDrop
];
export function setupDragAndDrop() {
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onDrop = async (e) => {
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
(store.ui.sidebarContainer && store.ui.sidebarContainer.contains(e.target));
if (isSidebarDrop) {
e.preventDefault();
e.stopPropagation();
return;
}
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
const jsonStr = e.dataTransfer.getData("application/json");
const isOurMedia = url && url.includes("/view?");
const isOurWorkflow = jsonStr && jsonStr.includes('"nodes"');
// If it's an external file drop from outside the browser, let ComfyUI handle it natively
if (!isOurMedia && !isOurWorkflow) {
return;
}
// Synchronously stop ComfyUI core drop listeners from double-handling
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
for (const handler of dropHandlers) {
if (await handler(e)) {
return;
}
}
};
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop, true);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop, true);
};
}
+114
View File
@@ -0,0 +1,114 @@
import { store } from "../core/store.js";
export class SidebarOverlay {
constructor(options = {}) {
this.onDestroy = options.onDestroy || (() => {});
this.cleanupFns = [];
// Root container
this.container = document.createElement("div");
this.container.className = `comfy-sidebar-comparison-overlay ${options.className || ""}`;
Object.assign(this.container.style, {
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
background: options.background || "color-mix(in srgb, var(--comfy-menu-bg, #121212) 95%, transparent)",
backdropFilter: "blur(8px)",
color: "var(--fg-color, #eee)",
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", zIndex: "1000", boxSizing: "border-box",
overflow: "hidden", pointerEvents: "auto", userSelect: "none"
});
// Close button
this.closeBtn = document.createElement("span");
this.closeBtn.className = "pi pi-times";
this.closeBtn.title = "Close (Esc)";
Object.assign(this.closeBtn.style, {
position: "absolute", top: "16px", right: "24px", zIndex: "30",
cursor: "pointer", fontSize: "18px",
color: "var(--desc-color, #aaa)",
transition: "all 0.15s ease",
background: "var(--comfy-input-bg, rgba(20, 20, 20, 0.6))",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.1))",
borderRadius: "50%", padding: "6px"
});
this.closeBtn.onmouseenter = () => {
this.closeBtn.style.color = "var(--fg-color, #fff)";
this.closeBtn.style.borderColor = "var(--fg-color, #fff)";
};
this.closeBtn.onmouseleave = () => {
this.closeBtn.style.color = "var(--desc-color, #aaa)";
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
};
this.closeBtn.onclick = () => this.destroy();
this.container.appendChild(this.closeBtn);
// Backdrop click to close
this.container.addEventListener("click", (e) => {
if (e.target === this.container) this.destroy();
});
// Sidebar boundary alignment
this.updateOverlayBounds = this.updateOverlayBounds.bind(this);
this.updateOverlayBounds();
window.addEventListener("resize", this.updateOverlayBounds);
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
if (sidebarEl && window.ResizeObserver) {
const ro = new ResizeObserver(this.updateOverlayBounds);
ro.observe(sidebarEl);
this.cleanupFns.push(() => ro.disconnect());
}
// Global Key Handler (Esc to close)
const onKeyDown = (e) => {
if (e.key === "Escape") {
e.preventDefault();
this.destroy();
}
if (options.onKeyDown) options.onKeyDown(e);
};
document.addEventListener("keydown", onKeyDown);
this.cleanupFns.push(() => document.removeEventListener("keydown", onKeyDown));
document.body.appendChild(this.container);
}
updateOverlayBounds() {
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.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);
this.container.style.left = `${leftOffset}px`;
this.container.style.width = `calc(100vw - ${leftOffset}px)`;
this.container.style.right = "0px";
} else {
const rightOffset = Math.max(0, window.innerWidth - rect.left);
this.container.style.left = "0px";
this.container.style.width = `calc(100vw - ${rightOffset}px)`;
this.container.style.right = `${rightOffset}px`;
}
this.container.style.top = `${Math.max(0, rect.top)}px`;
this.container.style.height = `calc(100vh - ${Math.max(0, rect.top)}px)`;
} else {
this.container.style.left = "0px";
this.container.style.width = "100vw";
this.container.style.top = "0px";
this.container.style.height = "100vh";
}
}
addCleanup(fn) {
this.cleanupFns.push(fn);
}
destroy() {
for (const fn of this.cleanupFns.splice(0)) {
try { fn(); } catch (e) {}
}
this.container.remove();
this.onDestroy();
}
}
+517
View File
@@ -0,0 +1,517 @@
export function injectStyles() {
if (document.getElementById("comfy-sidebar-styles")) return;
const style = document.createElement("style");
style.id = "comfy-sidebar-styles";
style.textContent = `
.comfy-sidebar-card {
background: var(--comfy-input-bg, #181818);
border-radius: 4px; padding: 8px; position: relative;
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
user-select: none; -webkit-user-select: none;
border: 2px solid var(--border-color, #333);
color: var(--comfy-input-color, var(--fg-color, #eee));
}
.comfy-sidebar-card:hover {
border-color: var(--p-primary-color, var(--primary-color, #555)) !important;
transition: border-color 0.2s, background-color 0.2s;
}
.comfy-sidebar-card.active { --border-color: #3b82f6; --hover-color: #60a5fa; }
.comfy-sidebar-card.pending { --border-color: #6c757d; --hover-color: #adb5bd; }
.comfy-sidebar-card.cancelled { --border-color: #ffc107; --hover-color: #ffe082; }
.comfy-sidebar-card.error { --border-color: #dc3545; --hover-color: #f87171; }
.comfy-sidebar-card.pending .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active .comfy-sidebar-queue-cancel-btn {
display: none !important;
}
.comfy-sidebar-card.pending:hover .comfy-sidebar-queue-cancel-btn,
.comfy-sidebar-card.active:hover .comfy-sidebar-queue-cancel-btn {
display: flex !important;
}
/* Prevent action hover buttons (Delete/JSON/Node) from showing on pending & running cards */
.comfy-sidebar-card.pending .comfy-sidebar-hover-panel,
.comfy-sidebar-card.active .comfy-sidebar-hover-panel,
.comfy-sidebar-card.pending .comfy-sidebar-left-hover-panel,
.comfy-sidebar-card.active .comfy-sidebar-left-hover-panel {
display: none !important;
}
.comfy-sidebar-card-timer {
position: absolute; top: 6px; left: 8px; font-size: 10px;
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
}
.comfy-sidebar-queue-cancel-btn {
display: none;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
background-color: #7f1d1d !important;
color: #e2e8f0 !important;
font-size: 11px !important;
border-radius: 6px !important;
cursor: pointer !important;
transition: background-color 0.15s ease, color 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
}
.comfy-sidebar-queue-cancel-btn:hover {
background-color: #991b1b !important;
color: #ffffff !important;
}
.comfy-sidebar-hover-panel,
.comfy-sidebar-left-hover-panel {
display: none !important;
}
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-hover-panel,
.comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-left-hover-panel {
display: flex !important;
}
/* Card Action Buttons */
.comfy-sidebar-card-action-btn {
display: inline-flex;
align-items: center !important;
justify-content: center !important;
width: 30px !important;
height: 30px !important;
background-color: rgba(0, 0, 0, 0.75) !important;
color: #e2e8f0 !important;
font-size: 13px !important;
border-radius: 6px !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
z-index: 20 !important;
transform: translateZ(0);
}
.comfy-sidebar-card-action-btn:hover {
background-color: rgba(0, 0, 0, 0.95) !important;
color: #ffffff !important;
}
/* Delete Confirmation Active State */
.comfy-sidebar-card-action-btn.confirm-delete,
.comfy-sidebar-card-action-btn.confirm-delete:hover {
background-color: #dc3545 !important;
color: #ffffff !important;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
}
/* Disk Delete Confirmation */
.comfy-sidebar-card-action-btn.confirm-delete-disk,
.comfy-sidebar-card-action-btn.confirm-delete-disk:hover {
background-color: #7f1d1d !important;
color: #ffc9c9 !important;
box-shadow: 0 0 10px rgba(220, 38, 38, 0.85) !important;
border: 1px solid #ef4444 !important;
}
/* Dynamic Ctrl Key Hover State */
.comfy-sidebar-del-label {
display: none;
margin-left: 5px;
font-size: 10px;
font-weight: bold;
white-space: nowrap;
font-family: sans-serif;
letter-spacing: 0.3px;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card-action-btn:not(.comfy-sidebar-btn-del) {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-left-hover-panel {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del {
background-color: #991b1b !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
width: auto !important;
padding: 0 8px !important;
border-radius: 6px !important;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6) !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del .comfy-sidebar-del-label {
display: inline;
}
/* Translucent Folder Indicator on Ctrl Press */
.comfy-sidebar-ctrl-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #38bdf8;
z-index: 15;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
border-radius: 4px;
}
.comfy-sidebar-ctrl-overlay i {
font-size: 26px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
}
.comfy-sidebar-ctrl-overlay span {
font-size: 11px;
font-weight: bold;
color: #f1f5f9;
font-family: sans-serif;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-ctrl-overlay {
opacity: 1;
}
.comfy-sidebar-header-btn {
background: transparent;
color: var(--desc-color, #aaa);
border: 1px solid var(--border-color, #555);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease-in-out;
}
.comfy-sidebar-header-btn:hover {
border-color: var(--fg-color, #eee);
color: var(--fg-color, #eee);
}
.comfy-sidebar-icon-btn {
cursor: pointer;
font-size: 13px;
opacity: 0.6;
transition: opacity 0.15s ease-in-out;
}
.comfy-sidebar-icon-btn:hover {
opacity: 1;
}
.comfy-sidebar-scroll-top-btn {
position: absolute;
bottom: 14px;
right: 28px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(30, 30, 30, 0.9);
color: #eee;
border: 1px solid var(--border-color, #555);
cursor: pointer;
font-size: 13px;
display: none;
align-items: center;
justify-content: center;
z-index: 999;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
transition: all 0.15s ease-in-out;
opacity: 0.85;
}
.comfy-sidebar-scroll-top-btn:hover {
opacity: 1;
background: var(--p-primary-color, #3b82f6);
color: #fff;
border-color: var(--p-primary-color, #3b82f6);
}
/* 3D Preview Cards */
.comfy-sidebar-3d-wrapper {
width: 100%;
height: 160px;
background: linear-gradient(135deg, #1e1e2e 0%, #0f172a 100%);
border-radius: 4px;
display: flex;
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-3d-wrapper:hover {
border-color: #3b82f6;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
}
.comfy-sidebar-3d-preview-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.45);
transition: filter 0.2s ease, transform 0.2s ease;
pointer-events: none;
z-index: 1;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-preview-img {
filter: brightness(0.6);
transform: scale(1.03);
}
.comfy-sidebar-3d-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
.comfy-sidebar-3d-icon {
font-size: 38px;
color: #60a5fa;
margin-bottom: 6px;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8));
transition: transform 0.2s ease;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-icon {
transform: scale(1.1) rotate(6deg);
}
.comfy-sidebar-3d-badge {
position: absolute;
top: 6px;
right: 6px;
background: rgba(59, 130, 246, 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-3d-title {
font-size: 10px;
color: #f1f5f9;
font-family: monospace;
max-width: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(0, 0, 0, 0.65);
padding: 2px 6px;
border-radius: 3px;
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); }
}
/* Generic / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
.comfy-sidebar-file-wrapper {
width: 100%;
min-height: 88px;
background: linear-gradient(135deg, #131d2e 0%, #0b1120 100%);
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px 10px;
box-sizing: border-box;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease-in-out;
user-select: none;
cursor: pointer;
gap: 8px;
}
.comfy-sidebar-file-wrapper:hover {
border-color: #0ea5e9;
box-shadow: 0 0 14px rgba(14, 165, 233, 0.35);
transform: translateY(-1px);
}
.comfy-sidebar-file-badge {
background: rgba(14, 165, 233, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 0 6px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
white-space: nowrap;
}
.comfy-sidebar-file-title {
font-size: 11px;
color: #f1f5f9;
font-family: monospace;
font-weight: 600;
word-break: break-all;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.comfy-sidebar-file-icon {
font-size: 14px;
color: #38bdf8;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-3px);
}
.comfy-sidebar-file-subtext {
font-size: 9px;
color: #94a3b8;
font-family: sans-serif;
display: flex;
align-items: center;
gap: 4px;
opacity: 0.9;
}
.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);
}
+336
View File
@@ -0,0 +1,336 @@
import { SidebarOverlay } from "./overlay.js";
// 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, "&amp;").replace(/</g, "&lt;").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 = () => {}) {
let rawText = typeof textData === "object" ? (textData.text || "") : String(textData);
let pid = typeof textData === "object" ? textData.pid : "";
let isMarkdownMode = true;
const overlay = new SidebarOverlay({ onDestroy });
const readerBox = document.createElement("div");
Object.assign(readerBox.style, {
background: "var(--comfy-menu-bg, rgba(18, 18, 24, 0.95))",
backdropFilter: "blur(12px)",
border: "1px solid var(--border-color, 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.4)", display: "flex",
flexDirection: "column", gap: "14px", zIndex: "20",
color: "var(--fg-color, #eee)"
});
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: "var(--fg-color, #e2e8f0)",
wordBreak: "break-word",
userSelect: "text", "-webkit-user-select": "text",
background: "var(--comfy-input-bg, rgba(0, 0, 0, 0.2))",
padding: "16px 20px", borderRadius: "6px",
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.08))",
scrollbarWidth: "thin"
});
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);
overlay.container.appendChild(readerBox);
return {
isText: true,
loadTarget(targetData) {
if (typeof targetData !== "object" || !targetData.text) {
overlay.destroy();
onSwitchMedia(targetData);
return;
}
rawText = targetData.text || "";
pid = targetData.pid || "";
title.textContent = pid ? `Text Output #${pid}` : "Text Output";
updateView();
},
destroy: () => overlay.destroy()
};
}
+408
View File
@@ -0,0 +1,408 @@
import { app } from "/scripts/app.js";
const FILE_EXT_REGEX = /\.(safetensors|ckpt|pt|pth|bin|latent|onnx|engine|gguf|lora|zip|tar|gz|7z|json|csv|parquet|txt|yaml|yml|xml|pdf|blend|fbx|obj|glb|gltf|stl|ply|splat|spz|ksplat|png|jpg|jpeg|webp|gif|bmp|tiff|svg|mp4|webm|wav|mp3|ogg|flac|m4a|aac|opus)$/i;
export function getFilenameFromUrl(url) {
if (!url) return "";
if (typeof url === "object") {
url = url.filename || url.url || "";
}
const str = String(url);
try {
const origin = window.location?.origin && window.location.origin !== "null" ? window.location.origin : "http://localhost";
const u = new URL(str, origin);
if (u.searchParams && u.searchParams.get("filename")) {
return u.searchParams.get("filename");
}
const pathname = u.pathname || "";
return pathname.split("/").pop() || "";
} catch (e) {
const m = str.match(/[?&]filename=([^&#]+)/);
if (m) {
try { return decodeURIComponent(m[1]); } catch (err) { return m[1]; }
}
return str.split("?")[0].split("#")[0].split("/").pop() || "";
}
}
const checkExt = (url, extensions) => {
if (!url) return false;
const name = getFilenameFromUrl(url).toLowerCase();
const s = String(url).toLowerCase().split("?")[0].split("#")[0];
return extensions.some(ext => name.endsWith(ext) || s.endsWith(ext));
};
export const isImageFormat = (url) => {
if (!url) return false;
if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true;
return checkExt(url, [
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
".tiff", ".tif", ".svg", ".avif", ".ico", ".apng"
]);
};
export const isVideoFormat = (url) => {
return checkExt(url, [".mp4", ".webm", ".mov", ".mkv"]);
};
export const is3DFormat = (url) => {
return checkExt(url, [
".glb", ".gltf", ".obj", ".ply", ".stl",
".splat", ".spz", ".ksplat", ".fbx"
]);
};
export const isAudioFormat = (url) => {
return checkExt(url, [
".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", ".opus"
]);
};
export function parseWorkflow(workflow) {
if (!workflow) return null;
if (typeof workflow === "object") return workflow;
if (typeof workflow === "string") {
try { return JSON.parse(workflow); } catch (e) { return null; }
}
return null;
}
export function isNodeIgnored(nodeId, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
if (node?.properties?.ignoreInQueue !== undefined) {
return !!node.properties.ignoreInQueue;
}
}
if (!workflow && app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(String(nodeId))) : null;
if (canvasNode?.properties?.ignoreInQueue !== undefined) {
return !!canvasNode.properties.ignoreInQueue;
}
}
return false;
}
export function getNodeTitle(nodeId, rawWorkflow, promptGraph = null) {
const idStr = String(nodeId);
const workflow = parseWorkflow(rawWorkflow);
if (workflow && Array.isArray(workflow.nodes)) {
const node = workflow.nodes.find(n => String(n.id) === idStr);
if (node) {
return node.title || node.type || null;
}
}
if (promptGraph && promptGraph[idStr]) {
const pNode = promptGraph[idStr];
if (pNode._meta?.title) return pNode._meta.title;
if (pNode.class_type) return pNode.class_type;
}
if (app.graph) {
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(idStr)) : null;
if (canvasNode) {
return canvasNode.title || canvasNode.type || null;
}
}
return null;
}
function cleanSubfolder(subfolder) {
if (!subfolder) return "";
let s = String(subfolder).replace(/\\/g, "/").trim();
s = s.replace(/^\/+|\/+$/g, ""); // Strip leading/trailing slashes
if (s.toLowerCase() === "output") return "";
if (s.toLowerCase().startsWith("output/")) {
s = s.substring(7);
}
return s;
}
function extractFileCandidate(val, subfolder = "", type = "output", nodeType = "") {
if (!val || typeof val !== "string") return null;
let trimmed = val.replace(/\\/g, "/").trim();
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
return null;
}
if (!isNaN(Number(trimmed))) {
return null;
}
// Extract any path ending with a known media or model extension from text messages
const fileMatch = trimmed.match(/([a-zA-Z0-9_\-\.\/]+\.(?:safetensors|ckpt|pt|bin|latent|png|jpg|jpeg|webp|mp4|webm|wav|mp3|glb|gltf))/i);
if (fileMatch) {
trimmed = fileMatch[1];
}
// Auto-append .safetensors if this is a LoRA node/subfolder without an extension
if (!trimmed.includes(".")) {
const typeLower = (nodeType || "").toLowerCase();
const subLower = (subfolder || "").toLowerCase();
const isLora = typeLower.includes("lora") || subLower.includes("lora") || trimmed.includes("lora");
const isLatent = typeLower.includes("latent");
if (isLora) {
trimmed += ".safetensors";
} else if (isLatent) {
trimmed += ".latent";
} else {
return null;
}
}
const cleanName = trimmed.split("/").pop()?.trim() || "";
if (!cleanName || cleanName === "." || cleanName === "..") return null;
const dotIndex = cleanName.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === cleanName.length - 1) {
return null;
}
const ext = cleanName.substring(dotIndex + 1).toLowerCase();
const base = cleanName.substring(0, dotIndex).trim();
if (!base || !ext) return null;
const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100);
if (!isValidExt) return null;
let detectedSub = subfolder;
if (trimmed.includes("/")) {
detectedSub = trimmed.substring(0, trimmed.lastIndexOf("/"));
}
detectedSub = cleanSubfolder(detectedSub);
return {
filename: cleanName,
subfolder: detectedSub,
type: type || "output"
};
}
export function findImagesInOutputs(outputs, rawWorkflow) {
const list = [];
const seen = new Set();
const workflow = parseWorkflow(rawWorkflow);
const addCandidate = (item) => {
if (!item || !item.filename) return;
const key = `${item.type || "output"}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
list.push(item);
}
};
const scan = (obj, sub = "", typ = "output", nodeType = "") => {
if (!obj) return;
if (typeof obj === "string") {
const cand = extractFileCandidate(obj, sub, typ, nodeType);
if (cand) addCandidate(cand);
return;
}
if (Array.isArray(obj)) {
obj.forEach(item => scan(item, sub, typ, nodeType));
return;
}
if (typeof obj === "object") {
const targetSub = obj.subfolder !== undefined ? obj.subfolder : sub;
const targetTyp = obj.type || typ || "output";
const fname = obj.filename || obj.name || obj.file || obj.path ||
obj.lora_name || obj.model_name || obj.save_name ||
obj.saved_file || obj.output_path || obj.file_name ||
obj.output || obj.saved;
if (fname && typeof fname === "string") {
const cand = extractFileCandidate(fname, targetSub, targetTyp, nodeType);
if (cand) {
addCandidate(cand);
return;
}
}
for (const key in obj) {
scan(obj[key], targetSub, targetTyp, nodeType || key);
}
}
};
if (outputs) {
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
const node = workflow?.nodes?.find(n => String(n.id) === String(nodeId));
const nodeType = node ? (node.type || "") : "";
scan(outputs[nodeId], "", "output", nodeType);
}
}
if (list.length > 0) return list;
// Fallback: Check workflow nodes for Save / Extract / Export nodes
if (workflow && Array.isArray(workflow.nodes)) {
const targetNodeIds = outputs ? Object.keys(outputs).map(String) : null;
for (const node of workflow.nodes) {
if (node && !node.properties?.ignoreInQueue) {
if (targetNodeIds && targetNodeIds.length > 0 && !targetNodeIds.includes(String(node.id))) {
continue;
}
const typeStr = (node.type || "").toLowerCase();
const isLoaderNode = typeStr.includes("load") || typeStr.includes("loader") || typeStr.includes("input");
const isSaveNode = !isLoaderNode && (
typeStr.includes("save") ||
typeStr.includes("extract") ||
typeStr.includes("export") ||
typeStr.includes("writer")
);
if (isSaveNode && Array.isArray(node.widgets_values)) {
for (const val of node.widgets_values) {
if (typeof val === "string" && val.trim().length > 0) {
let defaultSub = "";
if (typeStr.includes("lora")) defaultSub = "loras";
const cand = extractFileCandidate(val, defaultSub, "output", typeStr);
if (cand) {
cand.isFallback = true;
addCandidate(cand);
}
}
}
}
}
}
}
return list;
}
export function findTextsInOutputs(outputs, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (typeof item === 'string' && !FILE_EXT_REGEX.test(item.trim())) {
list.push(item);
} else if (item && typeof item === 'object' && item.text) {
if (Array.isArray(item.text)) list.push(...item.text);
else if (typeof item.text === 'string') list.push(item.text);
}
});
} else if (typeof val === 'string' && !FILE_EXT_REGEX.test(val.trim())) {
list.push(val);
} else if (val && typeof val === 'object' && val.text) {
if (Array.isArray(val.text)) list.push(...val.text);
else if (typeof val.text === 'string') list.push(val.text);
}
}
}
return list;
}
export function getRunOutputs(nodeOutputs, rawWorkflow, promptGraph = null) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
const nodeOut = nodeOutputs[nodeId];
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
// Even if the output dict is empty, check if this node was a Save/Extract node
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
const title = getNodeTitle(nodeId, workflow, promptGraph);
list.push({ nodeId, images: imgs, nodeTitle: title });
}
continue;
}
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
const title = getNodeTitle(nodeId, workflow, promptGraph);
list.push({ nodeId, images: imgs, nodeTitle: title });
}
}
return list;
}
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
return runOutputs[runOutputs.length - 1].images || [];
}
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();
if (state.pid && String(state.pid).toLowerCase().includes(q)) return true;
if (state.texts && state.texts.some(t => String(t).toLowerCase().includes(q))) return true;
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
const workflow = parseWorkflow(state.workflow);
if (workflow && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.title && node.title.toLowerCase().includes(q)) return true;
if (node.type && node.type.toLowerCase().includes(q)) return true;
if (Array.isArray(node.widgets)) {
for (const w of node.widgets) {
if (w && w.value !== undefined && w.value !== null) {
if (String(w.value).toLowerCase().includes(q)) return true;
}
}
}
}
}
return false;
}
export async function extractWorkflowFromPng(imageUrl) {
try {
const res = await fetch(imageUrl);
const buffer = await res.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) {
return null;
}
let offset = 8;
const utf8Decoder = new TextDecoder("utf-8");
while (offset < buffer.byteLength) {
const length = view.getUint32(offset);
const type = utf8Decoder.decode(new Uint8Array(buffer, offset + 4, 4));
if (type === "tEXt") {
const chunkData = new Uint8Array(buffer, offset + 8, length);
const nullIdx = chunkData.indexOf(0);
if (nullIdx !== -1) {
const keyword = utf8Decoder.decode(chunkData.subarray(0, nullIdx));
if (keyword === "workflow") {
const text = utf8Decoder.decode(chunkData.subarray(nullIdx + 1));
return JSON.parse(text);
}
}
} else if (type === "IEND") {
break;
}
offset += 12 + length;
}
} catch (e) {
console.warn("Comfy Sidebar: Could not extract workflow from PNG", e);
}
return null;
}
+568
View File
@@ -0,0 +1,568 @@
import { is3DFormat, getFilenameFromUrl } from "./utils.js";
import { SidebarOverlay } from "./overlay.js";
let cachedThreeLibs = null;
async function getThreeLibs() {
if (cachedThreeLibs) return cachedThreeLibs;
// Check if Three.js was already loaded on window by another ComfyUI 3D node
if (window.THREE) {
const OrbitControls = window.THREE.OrbitControls || window.OrbitControls;
const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader;
if (OrbitControls && GLTFLoader) {
cachedThreeLibs = {
...window.THREE,
OrbitControls,
GLTFLoader,
OBJLoader: window.THREE.OBJLoader || window.OBJLoader,
STLLoader: window.THREE.STLLoader || window.STLLoader,
PLYLoader: window.THREE.PLYLoader || window.PLYLoader
};
return cachedThreeLibs;
}
}
const loadFromBase = async (base) => {
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
import(`${base}three@0.170.0`),
import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null)
]);
return { three, controls, gltf, obj, stl, ply };
};
try {
let loaded = null;
// 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes)
try {
loaded = await loadFromBase("/comfy-sidebar/three-proxy/");
window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/";
} catch (proxyErr) {
// 2. Fall back to direct CDN if proxy is unreachable
console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr);
loaded = await loadFromBase("https://esm.sh/");
window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/";
}
const { three, controls, gltf, obj, stl, ply } = loaded;
cachedThreeLibs = {
...three,
OrbitControls: controls.OrbitControls || controls.default || controls,
GLTFLoader: gltf.GLTFLoader || gltf.default || gltf,
OBJLoader: obj?.OBJLoader || obj?.default || obj,
STLLoader: stl?.STLLoader || stl?.default || stl,
PLYLoader: ply?.PLYLoader || ply?.default || ply
};
return cachedThreeLibs;
} catch (err) {
console.error("Comfy Sidebar: Failed to load Three.js libraries:", err);
return null;
}
}
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}) {
const overlay = new SidebarOverlay({
className: "comfy-sidebar-3d-overlay-root",
onDestroy
});
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 = getFilenameFromUrl(baseSrc) || "3D Model";
header.appendChild(titleText);
overlay.container.appendChild(header);
const canvasContainer = document.createElement("div");
Object.assign(canvasContainer.style, {
width: "100%", height: "100%", position: "relative",
display: "flex", alignItems: "center", justifyContent: "center"
});
overlay.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...`;
overlay.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}...`;
const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/";
if (format === "OBJ") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
const OBJExporter = mod.OBJExporter || mod.default || mod;
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 mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`);
const GLTFExporter = mod.GLTFExporter || mod.default || mod;
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 mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`);
const STLExporter = mod.STLExporter || mod.default || mod;
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();
}
};
overlay.addCleanup(cleanup3D);
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 = getFilenameFromUrl(srcUrl) || "3D Model";
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.innerHTML = `
<div style="text-align: center; max-width: 320px; line-height: 1.4;">
<span style="color: #f87171; font-weight: bold;">Failed to load 3D engine.</span><br>
<span style="font-size: 11px; color: #94a3b8;">Blocked by ComfyUI Content Security Policy (CSP). Check if <code>--disable-api-nodes</code> is enabled in your startup arguments, or vendor Three.js locally.</span>
</div>
`;
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)
);
overlay.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);
overlay.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);
overlay.addCleanup(() => window.removeEventListener("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 filename = getFilenameFromUrl(srcUrl);
const ext = (filename.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)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
initSceneAndLoad(targetSrc);
},
destroy: () => overlay.destroy()
};
}