From 88088e1071873ed124a7ea1dacaa3e65951e68f8 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 13 May 2025 14:42:28 +0800
Subject: [PATCH] Restructure the code of loras_widget into smaller, more
manageable modules.
---
web/comfyui/loras_widget.js | 677 +------------------------
web/comfyui/loras_widget_components.js | 303 +++++++++++
web/comfyui/loras_widget_events.js | 257 ++++++++++
web/comfyui/loras_widget_utils.js | 109 ++++
4 files changed, 688 insertions(+), 658 deletions(-)
create mode 100644 web/comfyui/loras_widget_components.js
create mode 100644 web/comfyui/loras_widget_events.js
create mode 100644 web/comfyui/loras_widget_utils.js
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
+ });
+ }
+ }
+}