diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index f411d3b5..cb6fa9bf 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -1,5 +1,17 @@ -import { api } from "../../scripts/api.js"; import { app } from "../../scripts/app.js"; +import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js"; +import { + parseLoraValue, + formatLoraValue, + updateWidgetHeight, + shouldShowClipEntry, + syncClipStrengthIfCollapsed, + LORA_ENTRY_HEIGHT, + HEADER_HEIGHT, + CONTAINER_PADDING, + EMPTY_CONTAINER_HEIGHT +} from "./loras_widget_utils.js"; +import { initDrag, createContextMenu } from "./loras_widget_events.js"; export function addLorasWidget(node, name, opts, callback) { // Create container for loras @@ -27,586 +39,9 @@ export function addLorasWidget(node, name, opts, callback) { // Initialize default value const defaultValue = opts?.defaultVal || []; - // Fixed sizes for component calculations - const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry - const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry - const HEADER_HEIGHT = 40; // Height of the header section - const CONTAINER_PADDING = 12; // Top and bottom padding - const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present - - // Remove expandedClipEntries Set since we'll determine expansion based on strength values - - // Parse LoRA entries from value - const parseLoraValue = (value) => { - if (!value) return []; - return Array.isArray(value) ? value : []; - }; - - // Format LoRA data - const formatLoraValue = (loras) => { - return loras; - }; - - // Function to update widget height consistently - const updateWidgetHeight = (height) => { - // Ensure minimum height - const finalHeight = Math.max(defaultHeight, height); - - // Update CSS variables - container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`); - container.style.setProperty('--comfy-widget-height', `${finalHeight}px`); - - // Force node to update size after a short delay to ensure DOM is updated - if (node) { - setTimeout(() => { - node.setDirtyCanvas(true, true); - }, 10); - } - }; - - // Function to create toggle element - const createToggle = (active, onChange) => { - const toggle = document.createElement("div"); - toggle.className = "comfy-lora-toggle"; - - updateToggleStyle(toggle, active); - - toggle.addEventListener("click", (e) => { - e.stopPropagation(); - onChange(!active); - }); - - return toggle; - }; - - // Helper function to update toggle style - function updateToggleStyle(toggleEl, active) { - Object.assign(toggleEl.style, { - width: "18px", - height: "18px", - borderRadius: "4px", - cursor: "pointer", - transition: "all 0.2s ease", - backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)", - border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`, - }); - - // Add hover effect - toggleEl.onmouseenter = () => { - toggleEl.style.transform = "scale(1.05)"; - toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)"; - }; - - toggleEl.onmouseleave = () => { - toggleEl.style.transform = "scale(1)"; - toggleEl.style.boxShadow = "none"; - }; - } - - // Create arrow button for strength adjustment - const createArrowButton = (direction, onClick) => { - const button = document.createElement("div"); - button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`; - - Object.assign(button.style, { - width: "16px", - height: "16px", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - userSelect: "none", - fontSize: "12px", - color: "rgba(226, 232, 240, 0.8)", - transition: "all 0.2s ease", - }); - - button.textContent = direction === "left" ? "◀" : "▶"; - - button.addEventListener("click", (e) => { - e.stopPropagation(); - onClick(); - }); - - // Add hover effect - button.onmouseenter = () => { - button.style.color = "white"; - button.style.transform = "scale(1.2)"; - }; - - button.onmouseleave = () => { - button.style.color = "rgba(226, 232, 240, 0.8)"; - button.style.transform = "scale(1)"; - }; - - return button; - }; - - // Preview tooltip class - class PreviewTooltip { - constructor() { - 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', - }); - document.body.appendChild(this.element); - this.hideTimeout = null; - - // Add global click event to hide tooltip - document.addEventListener('click', () => this.hide()); - - // Add scroll event listener - document.addEventListener('scroll', () => this.hide(), true); - } - - async show(loraName, x, y) { - try { - // Clear previous hide timer - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - this.hideTimeout = null; - } - - // Don't redisplay the same lora preview - if (this.element.style.display === 'block' && this.currentLora === loraName) { - return; - } - - this.currentLora = loraName; - - // Get preview URL - const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { - 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'); - } - - // Clear existing content - while (this.element.firstChild) { - this.element.removeChild(this.element.firstChild); - } - - // Create media container with relative positioning - const mediaContainer = document.createElement('div'); - Object.assign(mediaContainer.style, { - position: 'relative', - maxWidth: '300px', - maxHeight: '300px', - }); - - const isVideo = data.preview_url.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; - } - - mediaElement.src = data.preview_url; - - // Create name label with absolute positioning - const nameLabel = document.createElement('div'); - nameLabel.textContent = loraName; - Object.assign(nameLabel.style, { - position: 'absolute', - bottom: '0', - left: '0', - right: '0', - padding: '8px', - color: 'rgba(255, 255, 255, 0.95)', - 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); - - // Add fade-in effect - this.element.style.opacity = '0'; - this.element.style.display = 'block'; - this.position(x, y); - - requestAnimationFrame(() => { - 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) { - // Ensure preview box doesn't exceed viewport boundaries - const rect = this.element.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let left = x + 10; // Default 10px offset to the right of mouse - let top = y + 10; // Default 10px offset below mouse - - // Check right boundary - if (left + rect.width > viewportWidth) { - left = x - rect.width - 10; - } - - // Check bottom boundary - if (top + rect.height > viewportHeight) { - top = y - rect.height - 10; - } - - Object.assign(this.element.style, { - left: `${left}px`, - top: `${top}px` - }); - } - - hide() { - // Use fade-out effect - if (this.element.style.display === 'block') { - this.element.style.opacity = '0'; - this.hideTimeout = setTimeout(() => { - this.element.style.display = 'none'; - this.currentLora = null; - // Stop video playback - const video = this.element.querySelector('video'); - if (video) { - video.pause(); - } - this.hideTimeout = null; - }, 150); - } - } - - cleanup() { - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); - } - // Remove all event listeners - document.removeEventListener('click', () => this.hide()); - document.removeEventListener('scroll', () => this.hide(), true); - this.element.remove(); - } - } - // Create preview tooltip instance const previewTooltip = new PreviewTooltip(); - - // Function to create menu item - const createMenuItem = (text, icon, onClick) => { - const menuItem = document.createElement('div'); - Object.assign(menuItem.style, { - padding: '6px 20px', - cursor: 'pointer', - color: 'rgba(226, 232, 240, 0.9)', - fontSize: '13px', - userSelect: 'none', - display: 'flex', - alignItems: 'center', - gap: '8px', - }); - - // Create icon element - const iconEl = document.createElement('div'); - iconEl.innerHTML = icon; - Object.assign(iconEl.style, { - width: '14px', - height: '14px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }); - - // Create text element - const textEl = document.createElement('span'); - textEl.textContent = text; - - menuItem.appendChild(iconEl); - menuItem.appendChild(textEl); - - menuItem.addEventListener('mouseenter', () => { - menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; - }); - - menuItem.addEventListener('mouseleave', () => { - menuItem.style.backgroundColor = 'transparent'; - }); - - if (onClick) { - menuItem.addEventListener('click', onClick); - } - - return menuItem; - }; - - // Function to handle strength adjustment via dragging - const handleStrengthDrag = (name, initialStrength, initialX, event, widget, isClipStrength = false) => { - // Calculate drag sensitivity (how much the strength changes per pixel) - // Using 0.01 per 10 pixels of movement - const sensitivity = 0.001; - - // Get the current mouse position - const currentX = event.clientX; - - // Calculate the distance moved - const deltaX = currentX - initialX; - - // Calculate the new strength value based on movement - // Moving right increases, moving left decreases - let newStrength = Number(initialStrength) + (deltaX * sensitivity); - - // Limit the strength to reasonable bounds (now between -10 and 10) - newStrength = Math.max(-10, Math.min(10, newStrength)); - newStrength = Number(newStrength.toFixed(2)); - - // Update the lora data - const lorasData = parseLoraValue(widget.value); - const loraIndex = lorasData.findIndex(l => l.name === name); - - if (loraIndex >= 0) { - // Update the appropriate strength property based on isClipStrength flag - if (isClipStrength) { - lorasData[loraIndex].clipStrength = newStrength; - } else { - lorasData[loraIndex].strength = newStrength; - // Sync clipStrength if collapsed - syncClipStrengthIfCollapsed(lorasData[loraIndex]); - } - - // Update the widget value - widget.value = formatLoraValue(lorasData); - - // Force re-render to show updated strength value - renderLoras(widget.value, widget); - } - }; - // Function to initialize drag operation - const initDrag = (dragEl, name, widget, isClipStrength = false) => { - let isDragging = false; - let initialX = 0; - let initialStrength = 0; - - // Create a style element for drag cursor override if it doesn't exist - if (!document.getElementById('comfy-lora-drag-style')) { - const styleEl = document.createElement('style'); - styleEl.id = 'comfy-lora-drag-style'; - styleEl.textContent = ` - body.comfy-lora-dragging, - body.comfy-lora-dragging * { - cursor: ew-resize !important; - } - `; - document.head.appendChild(styleEl); - } - - // Create a drag handler - dragEl.addEventListener('mousedown', (e) => { - // Skip if clicking on toggle or strength control areas - if (e.target.closest('.comfy-lora-toggle') || - e.target.closest('input') || - e.target.closest('.comfy-lora-arrow')) { - return; - } - - // Store initial values - const lorasData = parseLoraValue(widget.value); - const loraData = lorasData.find(l => l.name === name); - - if (!loraData) return; - - initialX = e.clientX; - initialStrength = isClipStrength ? loraData.clipStrength : loraData.strength; - isDragging = true; - - // Add class to body to enforce cursor style globally - document.body.classList.add('comfy-lora-dragging'); - - // Prevent text selection during drag - e.preventDefault(); - }); - - // Use the document for move and up events to ensure drag continues - // even if mouse leaves the element - document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - - // Call the strength adjustment function - handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength); - - // Prevent showing the preview tooltip during drag - previewTooltip.hide(); - }); - - document.addEventListener('mouseup', () => { - if (isDragging) { - isDragging = false; - // Remove the class to restore normal cursor behavior - document.body.classList.remove('comfy-lora-dragging'); - } - }); - }; - - // Function to create context menu - const createContextMenu = (x, y, loraName, widget) => { - // Hide preview tooltip first - previewTooltip.hide(); - - // Remove existing context menu if any - const existingMenu = document.querySelector('.comfy-lora-context-menu'); - if (existingMenu) { - existingMenu.remove(); - } - - const menu = document.createElement('div'); - menu.className = 'comfy-lora-context-menu'; - Object.assign(menu.style, { - position: 'fixed', - left: `${x}px`, - top: `${y}px`, - backgroundColor: 'rgba(30, 30, 30, 0.95)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: '4px', - padding: '4px 0', - zIndex: 1000, - boxShadow: '0 2px 10px rgba(0,0,0,0.2)', - minWidth: '180px', - }); - - // View on Civitai option with globe icon - const viewOnCivitaiOption = createMenuItem( - 'View on Civitai', - '', - async () => { - menu.remove(); - document.removeEventListener('click', closeMenu); - - try { - // Get Civitai URL from API - const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, { - method: 'GET' - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || 'Failed to get Civitai URL'); - } - - const data = await response.json(); - if (data.success && data.civitai_url) { - // Open the URL in a new tab - window.open(data.civitai_url, '_blank'); - } else { - // Show error message if no Civitai URL - if (app && app.extensionManager && app.extensionManager.toast) { - app.extensionManager.toast.add({ - severity: 'warning', - summary: 'Not Found', - detail: 'This LoRA has no associated Civitai URL', - life: 3000 - }); - } else { - alert('This LoRA has no associated Civitai URL'); - } - } - } catch (error) { - console.error('Error getting Civitai URL:', error); - if (app && app.extensionManager && app.extensionManager.toast) { - app.extensionManager.toast.add({ - severity: 'error', - summary: 'Error', - detail: error.message || 'Failed to get Civitai URL', - life: 5000 - }); - } else { - alert('Error: ' + (error.message || 'Failed to get Civitai URL')); - } - } - } - ); - - // Delete option with trash icon - const deleteOption = createMenuItem( - 'Delete', - '', - () => { - menu.remove(); - document.removeEventListener('click', closeMenu); - - const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName); - widget.value = formatLoraValue(lorasData); - - if (widget.callback) { - widget.callback(widget.value); - } - } - ); - - // Save recipe option with bookmark icon - const saveOption = createMenuItem( - 'Save Recipe', - '', - () => { - menu.remove(); - document.removeEventListener('click', closeMenu); - saveRecipeDirectly(widget); - } - ); - - // Add separator - const separator = document.createElement('div'); - Object.assign(separator.style, { - margin: '4px 0', - borderTop: '1px solid rgba(255, 255, 255, 0.1)', - }); - - menu.appendChild(viewOnCivitaiOption); - menu.appendChild(deleteOption); - menu.appendChild(separator); - menu.appendChild(saveOption); - - document.body.appendChild(menu); - - // Close menu when clicking outside - const closeMenu = (e) => { - if (!menu.contains(e.target)) { - menu.remove(); - document.removeEventListener('click', closeMenu); - } - }; - setTimeout(() => document.addEventListener('click', closeMenu), 0); - }; - // Function to render loras from data const renderLoras = (value, widget) => { // Clear existing content @@ -635,7 +70,7 @@ export function addLorasWidget(node, name, opts, callback) { container.appendChild(emptyMessage); // Set fixed height for empty state - updateWidgetHeight(EMPTY_CONTAINER_HEIGHT); + updateWidgetHeight(container, EMPTY_CONTAINER_HEIGHT, defaultHeight, node); return; } @@ -812,7 +247,7 @@ export function addLorasWidget(node, name, opts, callback) { }); // Initialize drag functionality for strength adjustment - initDrag(loraEl, name, widget, false); + initDrag(loraEl, name, widget, false, previewTooltip, renderLoras); // Remove the preview tooltip events from loraEl loraEl.onmouseenter = () => { @@ -827,7 +262,7 @@ export function addLorasWidget(node, name, opts, callback) { loraEl.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); - createContextMenu(e.clientX, e.clientY, name, widget); + createContextMenu(e.clientX, e.clientY, name, widget, previewTooltip, renderLoras); }); // Create strength control @@ -1133,7 +568,7 @@ export function addLorasWidget(node, name, opts, callback) { }; // Add drag functionality to clip entry - initDrag(clipEl, name, widget, true); + initDrag(clipEl, name, widget, true, previewTooltip, renderLoras); container.appendChild(clipEl); } @@ -1141,7 +576,7 @@ export function addLorasWidget(node, name, opts, callback) { // Calculate height based on number of loras and fixed sizes const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 8) * LORA_ENTRY_HEIGHT); - updateWidgetHeight(calculatedHeight); + updateWidgetHeight(container, calculatedHeight, defaultHeight, node); }; // Store the value in a variable to avoid recursion @@ -1227,78 +662,4 @@ export function addLorasWidget(node, name, opts, callback) { }; return { minWidth: 400, minHeight: defaultHeight, widget }; -} - -// Function to directly save the recipe without dialog -async function saveRecipeDirectly(widget) { - try { - const prompt = await app.graphToPrompt(); - console.log(prompt); - // Show loading toast - if (app && app.extensionManager && app.extensionManager.toast) { - app.extensionManager.toast.add({ - severity: 'info', - summary: 'Saving Recipe', - detail: 'Please wait...', - life: 2000 - }); - } - - // Send the request to the backend API without workflow data - const response = await fetch('/api/recipes/save-from-widget', { - method: 'POST' - }); - - const result = await response.json(); - - // Show result toast - if (app && app.extensionManager && app.extensionManager.toast) { - if (result.success) { - app.extensionManager.toast.add({ - severity: 'success', - summary: 'Recipe Saved', - detail: 'Recipe has been saved successfully', - life: 3000 - }); - } else { - app.extensionManager.toast.add({ - severity: 'error', - summary: 'Error', - detail: result.error || 'Failed to save recipe', - life: 5000 - }); - } - } - } catch (error) { - console.error('Error saving recipe:', error); - - // Show error toast - if (app && app.extensionManager && app.extensionManager.toast) { - app.extensionManager.toast.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'), - life: 5000 - }); - } - } -} - -// Determine if clip entry should be shown - now based on expanded property or initial diff values -const shouldShowClipEntry = (loraData) => { - // If expanded property exists, use that - if (loraData.hasOwnProperty('expanded')) { - return loraData.expanded; - } - // Otherwise use the legacy logic - if values differ, it should be expanded - return Number(loraData.strength) !== Number(loraData.clipStrength); -} - -// Helper function to sync clipStrength with strength when collapsed -const syncClipStrengthIfCollapsed = (loraData) => { - // If not expanded (collapsed), sync clipStrength with strength - if (loraData.hasOwnProperty('expanded') && !loraData.expanded) { - loraData.clipStrength = loraData.strength; - } - return loraData; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/web/comfyui/loras_widget_components.js b/web/comfyui/loras_widget_components.js new file mode 100644 index 00000000..54990d99 --- /dev/null +++ b/web/comfyui/loras_widget_components.js @@ -0,0 +1,303 @@ +import { api } from "../../scripts/api.js"; + +// Function to create toggle element +export function createToggle(active, onChange) { + const toggle = document.createElement("div"); + toggle.className = "comfy-lora-toggle"; + + updateToggleStyle(toggle, active); + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + onChange(!active); + }); + + return toggle; +} + +// Helper function to update toggle style +export function updateToggleStyle(toggleEl, active) { + Object.assign(toggleEl.style, { + width: "18px", + height: "18px", + borderRadius: "4px", + cursor: "pointer", + transition: "all 0.2s ease", + backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)", + border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`, + }); + + // Add hover effect + toggleEl.onmouseenter = () => { + toggleEl.style.transform = "scale(1.05)"; + toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)"; + }; + + toggleEl.onmouseleave = () => { + toggleEl.style.transform = "scale(1)"; + toggleEl.style.boxShadow = "none"; + }; +} + +// Create arrow button for strength adjustment +export function createArrowButton(direction, onClick) { + const button = document.createElement("div"); + button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`; + + Object.assign(button.style, { + width: "16px", + height: "16px", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + userSelect: "none", + fontSize: "12px", + color: "rgba(226, 232, 240, 0.8)", + transition: "all 0.2s ease", + }); + + button.textContent = direction === "left" ? "◀" : "▶"; + + button.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + + // Add hover effect + button.onmouseenter = () => { + button.style.color = "white"; + button.style.transform = "scale(1.2)"; + }; + + button.onmouseleave = () => { + button.style.color = "rgba(226, 232, 240, 0.8)"; + button.style.transform = "scale(1)"; + }; + + return button; +} + +// Function to create menu item +export function createMenuItem(text, icon, onClick) { + const menuItem = document.createElement('div'); + Object.assign(menuItem.style, { + padding: '6px 20px', + cursor: 'pointer', + color: 'rgba(226, 232, 240, 0.9)', + fontSize: '13px', + userSelect: 'none', + display: 'flex', + alignItems: 'center', + gap: '8px', + }); + + // Create icon element + const iconEl = document.createElement('div'); + iconEl.innerHTML = icon; + Object.assign(iconEl.style, { + width: '14px', + height: '14px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }); + + // Create text element + const textEl = document.createElement('span'); + textEl.textContent = text; + + menuItem.appendChild(iconEl); + menuItem.appendChild(textEl); + + menuItem.addEventListener('mouseenter', () => { + menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; + }); + + menuItem.addEventListener('mouseleave', () => { + menuItem.style.backgroundColor = 'transparent'; + }); + + if (onClick) { + menuItem.addEventListener('click', onClick); + } + + return menuItem; +} + +// Preview tooltip class +export class PreviewTooltip { + constructor() { + 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', + }); + document.body.appendChild(this.element); + this.hideTimeout = null; + + // Add global click event to hide tooltip + document.addEventListener('click', () => this.hide()); + + // Add scroll event listener + document.addEventListener('scroll', () => this.hide(), true); + } + + async show(loraName, x, y) { + try { + // Clear previous hide timer + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + // Don't redisplay the same lora preview + if (this.element.style.display === 'block' && this.currentLora === loraName) { + return; + } + + this.currentLora = loraName; + + // Get preview URL + const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { + 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'); + } + + // Clear existing content + while (this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + // Create media container with relative positioning + const mediaContainer = document.createElement('div'); + Object.assign(mediaContainer.style, { + position: 'relative', + maxWidth: '300px', + maxHeight: '300px', + }); + + const isVideo = data.preview_url.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; + } + + mediaElement.src = data.preview_url; + + // Create name label with absolute positioning + const nameLabel = document.createElement('div'); + nameLabel.textContent = loraName; + Object.assign(nameLabel.style, { + position: 'absolute', + bottom: '0', + left: '0', + right: '0', + padding: '8px', + color: 'rgba(255, 255, 255, 0.95)', + 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); + + // Add fade-in effect + this.element.style.opacity = '0'; + this.element.style.display = 'block'; + this.position(x, y); + + requestAnimationFrame(() => { + 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) { + // Ensure preview box doesn't exceed viewport boundaries + const rect = this.element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x + 10; // Default 10px offset to the right of mouse + let top = y + 10; // Default 10px offset below mouse + + // Check right boundary + if (left + rect.width > viewportWidth) { + left = x - rect.width - 10; + } + + // Check bottom boundary + if (top + rect.height > viewportHeight) { + top = y - rect.height - 10; + } + + Object.assign(this.element.style, { + left: `${left}px`, + top: `${top}px` + }); + } + + hide() { + // Use fade-out effect + if (this.element.style.display === 'block') { + this.element.style.opacity = '0'; + this.hideTimeout = setTimeout(() => { + this.element.style.display = 'none'; + this.currentLora = null; + // Stop video playback + const video = this.element.querySelector('video'); + if (video) { + video.pause(); + } + this.hideTimeout = null; + }, 150); + } + } + + cleanup() { + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + } + // Remove all event listeners + document.removeEventListener('click', () => this.hide()); + document.removeEventListener('scroll', () => this.hide(), true); + this.element.remove(); + } +} diff --git a/web/comfyui/loras_widget_events.js b/web/comfyui/loras_widget_events.js new file mode 100644 index 00000000..238a6f66 --- /dev/null +++ b/web/comfyui/loras_widget_events.js @@ -0,0 +1,257 @@ +import { api } from "../../scripts/api.js"; +import { createMenuItem } from "./loras_widget_components.js"; +import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly } from "./loras_widget_utils.js"; + +// Function to handle strength adjustment via dragging +export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) { + // Calculate drag sensitivity (how much the strength changes per pixel) + // Using 0.01 per 10 pixels of movement + const sensitivity = 0.001; + + // Get the current mouse position + const currentX = event.clientX; + + // Calculate the distance moved + const deltaX = currentX - initialX; + + // Calculate the new strength value based on movement + // Moving right increases, moving left decreases + let newStrength = Number(initialStrength) + (deltaX * sensitivity); + + // Limit the strength to reasonable bounds (now between -10 and 10) + newStrength = Math.max(-10, Math.min(10, newStrength)); + newStrength = Number(newStrength.toFixed(2)); + + // Update the lora data + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + // Update the appropriate strength property based on isClipStrength flag + if (isClipStrength) { + lorasData[loraIndex].clipStrength = newStrength; + } else { + lorasData[loraIndex].strength = newStrength; + // Sync clipStrength if collapsed + syncClipStrengthIfCollapsed(lorasData[loraIndex]); + } + + // Update the widget value + widget.value = formatLoraValue(lorasData); + + // Force re-render via callback + if (widget.callback) { + widget.callback(widget.value); + } + } +} + +// Function to initialize drag operation +export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) { + let isDragging = false; + let initialX = 0; + let initialStrength = 0; + + // Create a style element for drag cursor override if it doesn't exist + if (!document.getElementById('comfy-lora-drag-style')) { + const styleEl = document.createElement('style'); + styleEl.id = 'comfy-lora-drag-style'; + styleEl.textContent = ` + body.comfy-lora-dragging, + body.comfy-lora-dragging * { + cursor: ew-resize !important; + } + `; + document.head.appendChild(styleEl); + } + + // Create a drag handler + dragEl.addEventListener('mousedown', (e) => { + // Skip if clicking on toggle or strength control areas + if (e.target.closest('.comfy-lora-toggle') || + e.target.closest('input') || + e.target.closest('.comfy-lora-arrow')) { + return; + } + + // Store initial values + const lorasData = parseLoraValue(widget.value); + const loraData = lorasData.find(l => l.name === name); + + if (!loraData) return; + + initialX = e.clientX; + initialStrength = isClipStrength ? loraData.clipStrength : loraData.strength; + isDragging = true; + + // Add class to body to enforce cursor style globally + document.body.classList.add('comfy-lora-dragging'); + + // Prevent text selection during drag + e.preventDefault(); + }); + + // Use the document for move and up events to ensure drag continues + // even if mouse leaves the element + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + + // Call the strength adjustment function + handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength); + + // Force re-render to show updated strength value + if (renderFunction) { + renderFunction(widget.value, widget); + } + + // Prevent showing the preview tooltip during drag + if (previewTooltip) { + previewTooltip.hide(); + } + }); + + document.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + // Remove the class to restore normal cursor behavior + document.body.classList.remove('comfy-lora-dragging'); + } + }); +} + +// Function to create context menu +export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) { + // Hide preview tooltip first + if (previewTooltip) { + previewTooltip.hide(); + } + + // Remove existing context menu if any + const existingMenu = document.querySelector('.comfy-lora-context-menu'); + if (existingMenu) { + existingMenu.remove(); + } + + const menu = document.createElement('div'); + menu.className = 'comfy-lora-context-menu'; + Object.assign(menu.style, { + position: 'fixed', + left: `${x}px`, + top: `${y}px`, + backgroundColor: 'rgba(30, 30, 30, 0.95)', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '4px', + padding: '4px 0', + zIndex: 1000, + boxShadow: '0 2px 10px rgba(0,0,0,0.2)', + minWidth: '180px', + }); + + // View on Civitai option with globe icon + const viewOnCivitaiOption = createMenuItem( + 'View on Civitai', + '', + async () => { + menu.remove(); + document.removeEventListener('click', closeMenu); + + try { + // Get Civitai URL from API + const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, { + method: 'GET' + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to get Civitai URL'); + } + + const data = await response.json(); + if (data.success && data.civitai_url) { + // Open the URL in a new tab + window.open(data.civitai_url, '_blank'); + } else { + // Show error message if no Civitai URL + if (app && app.extensionManager && app.extensionManager.toast) { + app.extensionManager.toast.add({ + severity: 'warning', + summary: 'Not Found', + detail: 'This LoRA has no associated Civitai URL', + life: 3000 + }); + } else { + alert('This LoRA has no associated Civitai URL'); + } + } + } catch (error) { + console.error('Error getting Civitai URL:', error); + if (app && app.extensionManager && app.extensionManager.toast) { + app.extensionManager.toast.add({ + severity: 'error', + summary: 'Error', + detail: error.message || 'Failed to get Civitai URL', + life: 5000 + }); + } else { + alert('Error: ' + (error.message || 'Failed to get Civitai URL')); + } + } + } + ); + + // Delete option with trash icon + const deleteOption = createMenuItem( + 'Delete', + '', + () => { + menu.remove(); + document.removeEventListener('click', closeMenu); + + const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName); + widget.value = formatLoraValue(lorasData); + + if (widget.callback) { + widget.callback(widget.value); + } + + // Re-render + if (renderFunction) { + renderFunction(widget.value, widget); + } + } + ); + + // Save recipe option with bookmark icon + const saveOption = createMenuItem( + 'Save Recipe', + '', + () => { + menu.remove(); + document.removeEventListener('click', closeMenu); + saveRecipeDirectly(); + } + ); + + // Add separator + const separator = document.createElement('div'); + Object.assign(separator.style, { + margin: '4px 0', + borderTop: '1px solid rgba(255, 255, 255, 0.1)', + }); + + menu.appendChild(viewOnCivitaiOption); + menu.appendChild(deleteOption); + menu.appendChild(separator); + menu.appendChild(saveOption); + + document.body.appendChild(menu); + + // Close menu when clicking outside + const closeMenu = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener('click', closeMenu); + } + }; + setTimeout(() => document.addEventListener('click', closeMenu), 0); +} diff --git a/web/comfyui/loras_widget_utils.js b/web/comfyui/loras_widget_utils.js new file mode 100644 index 00000000..7eb662f8 --- /dev/null +++ b/web/comfyui/loras_widget_utils.js @@ -0,0 +1,109 @@ +import { app } from "../../scripts/app.js"; + +// Fixed sizes for component calculations +export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry +export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry +export const HEADER_HEIGHT = 40; // Height of the header section +export const CONTAINER_PADDING = 12; // Top and bottom padding +export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present + +// Parse LoRA entries from value +export function parseLoraValue(value) { + if (!value) return []; + return Array.isArray(value) ? value : []; +} + +// Format LoRA data +export function formatLoraValue(loras) { + return loras; +} + +// Function to update widget height consistently +export function updateWidgetHeight(container, height, defaultHeight, node) { + // Ensure minimum height + const finalHeight = Math.max(defaultHeight, height); + + // Update CSS variables + container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`); + container.style.setProperty('--comfy-widget-height', `${finalHeight}px`); + + // Force node to update size after a short delay to ensure DOM is updated + if (node) { + setTimeout(() => { + node.setDirtyCanvas(true, true); + }, 10); + } +} + +// Determine if clip entry should be shown - now based on expanded property or initial diff values +export function shouldShowClipEntry(loraData) { + // If expanded property exists, use that + if (loraData.hasOwnProperty('expanded')) { + return loraData.expanded; + } + // Otherwise use the legacy logic - if values differ, it should be expanded + return Number(loraData.strength) !== Number(loraData.clipStrength); +} + +// Helper function to sync clipStrength with strength when collapsed +export function syncClipStrengthIfCollapsed(loraData) { + // If not expanded (collapsed), sync clipStrength with strength + if (loraData.hasOwnProperty('expanded') && !loraData.expanded) { + loraData.clipStrength = loraData.strength; + } + return loraData; +} + +// Function to directly save the recipe without dialog +export async function saveRecipeDirectly() { + try { + const prompt = await app.graphToPrompt(); + // Show loading toast + if (app && app.extensionManager && app.extensionManager.toast) { + app.extensionManager.toast.add({ + severity: 'info', + summary: 'Saving Recipe', + detail: 'Please wait...', + life: 2000 + }); + } + + // Send the request to the backend API + const response = await fetch('/api/recipes/save-from-widget', { + method: 'POST' + }); + + const result = await response.json(); + + // Show result toast + if (app && app.extensionManager && app.extensionManager.toast) { + if (result.success) { + app.extensionManager.toast.add({ + severity: 'success', + summary: 'Recipe Saved', + detail: 'Recipe has been saved successfully', + life: 3000 + }); + } else { + app.extensionManager.toast.add({ + severity: 'error', + summary: 'Error', + detail: result.error || 'Failed to save recipe', + life: 5000 + }); + } + } + } catch (error) { + console.error('Error saving recipe:', error); + + // Show error toast + if (app && app.extensionManager && app.extensionManager.toast) { + app.extensionManager.toast.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'), + life: 5000 + }); + } + } +}