import { api } from "../../scripts/api.js"; /** * Lightweight preview tooltip that can display images or videos for different model types. */ export class PreviewTooltip { constructor(options = {}) { const { modelType = "loras", previewUrlResolver, displayNameFormatter, } = options; this.modelType = modelType; this.previewUrlResolver = typeof previewUrlResolver === "function" ? previewUrlResolver : (name) => this.defaultPreviewUrlResolver(name); this.displayNameFormatter = typeof displayNameFormatter === "function" ? displayNameFormatter : (name) => name; this.element = document.createElement("div"); Object.assign(this.element.style, { position: "fixed", zIndex: 9999, background: "rgba(0, 0, 0, 0.85)", borderRadius: "6px", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", display: "none", overflow: "hidden", maxWidth: "300px", pointerEvents: "none", }); document.body.appendChild(this.element); this.hideTimeout = null; this.isFromAutocomplete = false; this.currentModelName = null; this.globalClickHandler = (event) => { if (!event.target.closest(".comfy-autocomplete-dropdown")) { this.hide(); } }; document.addEventListener("click", this.globalClickHandler); this.globalScrollHandler = () => this.hide(); document.addEventListener("scroll", this.globalScrollHandler, true); } async defaultPreviewUrlResolver(modelName) { const response = await api.fetchApi( `/lm/${this.modelType}/preview-url?name=${encodeURIComponent(modelName)}`, { method: "GET" } ); if (!response.ok) { throw new Error("Failed to fetch preview URL"); } const data = await response.json(); if (!data.success || !data.preview_url) { throw new Error("No preview available"); } return { previewUrl: data.preview_url, displayName: data.display_name ?? modelName, }; } async resolvePreviewData(modelName) { const raw = await this.previewUrlResolver(modelName); if (!raw) { throw new Error("No preview data returned"); } if (typeof raw === "string") { return { previewUrl: raw, displayName: this.displayNameFormatter(modelName), }; } const { previewUrl, displayName } = raw; if (!previewUrl) { throw new Error("No preview URL available"); } return { previewUrl, displayName: displayName !== undefined ? displayName : this.displayNameFormatter(modelName), }; } async show(modelName, x, y, fromAutocomplete = false) { try { if (this.hideTimeout) { clearTimeout(this.hideTimeout); this.hideTimeout = null; } this.isFromAutocomplete = fromAutocomplete; if ( this.element.style.display === "block" && this.currentModelName === modelName ) { this.position(x, y); return; } this.currentModelName = modelName; const { previewUrl, displayName } = await this.resolvePreviewData( modelName ); while (this.element.firstChild) { this.element.removeChild(this.element.firstChild); } const mediaContainer = document.createElement("div"); Object.assign(mediaContainer.style, { position: "relative", maxWidth: "300px", maxHeight: "300px", }); const isVideo = previewUrl.endsWith(".mp4"); const mediaElement = isVideo ? document.createElement("video") : document.createElement("img"); Object.assign(mediaElement.style, { maxWidth: "300px", maxHeight: "300px", objectFit: "contain", display: "block", }); if (isVideo) { mediaElement.autoplay = true; mediaElement.loop = true; mediaElement.muted = true; mediaElement.controls = false; } const nameLabel = document.createElement("div"); nameLabel.textContent = displayName; Object.assign(nameLabel.style, { position: "absolute", bottom: "0", left: "0", right: "0", padding: "8px", color: "white", fontSize: "13px", fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif", background: "linear-gradient(transparent, rgba(0, 0, 0, 0.8))", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", textAlign: "center", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", }); mediaContainer.appendChild(mediaElement); mediaContainer.appendChild(nameLabel); this.element.appendChild(mediaContainer); this.element.style.opacity = "0"; this.element.style.display = "block"; const waitForLoad = () => new Promise((resolve) => { if (isVideo) { if (mediaElement.readyState >= 2) { resolve(); } else { mediaElement.addEventListener("loadeddata", resolve, { once: true, }); mediaElement.addEventListener("error", resolve, { once: true }); } } else if (mediaElement.complete) { resolve(); } else { mediaElement.addEventListener("load", resolve, { once: true }); mediaElement.addEventListener("error", resolve, { once: true }); } setTimeout(resolve, 1000); }); mediaElement.src = previewUrl; await waitForLoad(); requestAnimationFrame(() => { this.position(x, y); this.element.style.transition = "opacity 0.15s ease"; this.element.style.opacity = "1"; }); } catch (error) { console.warn("Failed to load preview:", error); } } position(x, y) { const rect = this.element.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left = x + 10; let top = y + 10; if (left + rect.width > viewportWidth) { left = x - rect.width - 10; } if (top + rect.height > viewportHeight) { top = y - rect.height - 10; } left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10)); top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10)); Object.assign(this.element.style, { left: `${left}px`, top: `${top}px`, }); } hide() { if (this.element.style.display === "block") { this.element.style.opacity = "0"; this.hideTimeout = setTimeout(() => { this.element.style.display = "none"; this.currentModelName = null; this.isFromAutocomplete = false; const video = this.element.querySelector("video"); if (video) { video.pause(); } this.hideTimeout = null; }, 150); } } cleanup() { if (this.hideTimeout) { clearTimeout(this.hideTimeout); } document.removeEventListener("click", this.globalClickHandler); document.removeEventListener("scroll", this.globalScrollHandler, true); this.element.remove(); } }