diff --git a/web/comfyui/legacy_loras_widget.js b/web/comfyui/legacy_loras_widget.js new file mode 100644 index 00000000..0e6c0129 --- /dev/null +++ b/web/comfyui/legacy_loras_widget.js @@ -0,0 +1,882 @@ +import { api } from "../../scripts/api.js"; +import { app } from "../../scripts/app.js"; + +export function addLorasWidget(node, name, opts, callback) { + // Create container for loras + const container = document.createElement("div"); + container.className = "comfy-loras-container"; + Object.assign(container.style, { + display: "flex", + flexDirection: "column", + gap: "8px", + padding: "6px", + backgroundColor: "rgba(40, 44, 52, 0.6)", + borderRadius: "6px", + width: "100%", + }); + + // Initialize default value + const defaultValue = opts?.defaultVal || []; + + // 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 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; + }; + + // 添加预览弹窗组件 + 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; // 添加超时处理变量 + + // 添加全局点击事件来隐藏tooltip + document.addEventListener('click', () => this.hide()); + + // 添加滚动事件监听 + document.addEventListener('scroll', () => this.hide(), true); + } + + async show(loraName, x, y) { + try { + // 清除之前的隐藏定时器 + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + // 如果已经显示同一个lora的预览,则不重复显示 + if (this.element.style.display === 'block' && this.currentLora === loraName) { + return; + } + + this.currentLora = loraName; + + // 获取预览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'); + } + + // 清除现有内容 + 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); + + // 添加淡入效果 + 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) { + // 确保预览框不超出视窗边界 + const rect = this.element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = x + 10; // 默认在鼠标右侧偏移10px + let top = y + 10; // 默认在鼠标下方偏移10px + + // 检查右边界 + if (left + rect.width > viewportWidth) { + left = x - rect.width - 10; + } + + // 检查下边界 + if (top + rect.height > viewportHeight) { + top = y - 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.currentLora = null; + // 停止视频播放 + 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.hide()); + document.removeEventListener('scroll', () => this.hide(), true); + this.element.remove(); + } + } + + // 创建预览tooltip实例 + 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 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); // Add the new menu option + 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 + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + // Parse the loras data + const lorasData = parseLoraValue(value); + + if (lorasData.length === 0) { + // Show message when no loras are added + const emptyMessage = document.createElement("div"); + emptyMessage.textContent = "No LoRAs added"; + Object.assign(emptyMessage.style, { + textAlign: "center", + padding: "20px 0", + color: "rgba(226, 232, 240, 0.8)", + fontStyle: "italic", + userSelect: "none", // Add this line to prevent text selection + WebkitUserSelect: "none", // For Safari support + MozUserSelect: "none", // For Firefox support + msUserSelect: "none", // For IE/Edge support + }); + container.appendChild(emptyMessage); + return; + } + + // Create header + const header = document.createElement("div"); + header.className = "comfy-loras-header"; + Object.assign(header.style, { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "4px 8px", + borderBottom: "1px solid rgba(226, 232, 240, 0.2)", + marginBottom: "8px" + }); + + // Add toggle all control + const allActive = lorasData.every(lora => lora.active); + const toggleAll = createToggle(allActive, (active) => { + // Update all loras active state + const lorasData = parseLoraValue(widget.value); + lorasData.forEach(lora => lora.active = active); + + const newValue = formatLoraValue(lorasData); + widget.value = newValue; + }); + + // Add label to toggle all + const toggleLabel = document.createElement("div"); + toggleLabel.textContent = "Toggle All"; + Object.assign(toggleLabel.style, { + color: "rgba(226, 232, 240, 0.8)", + fontSize: "13px", + marginLeft: "8px", + userSelect: "none", // Add this line to prevent text selection + WebkitUserSelect: "none", // For Safari support + MozUserSelect: "none", // For Firefox support + msUserSelect: "none", // For IE/Edge support + }); + + const toggleContainer = document.createElement("div"); + Object.assign(toggleContainer.style, { + display: "flex", + alignItems: "center", + }); + toggleContainer.appendChild(toggleAll); + toggleContainer.appendChild(toggleLabel); + + // Strength label + const strengthLabel = document.createElement("div"); + strengthLabel.textContent = "Strength"; + Object.assign(strengthLabel.style, { + color: "rgba(226, 232, 240, 0.8)", + fontSize: "13px", + marginRight: "8px", + userSelect: "none", // Add this line to prevent text selection + WebkitUserSelect: "none", // For Safari support + MozUserSelect: "none", // For Firefox support + msUserSelect: "none", // For IE/Edge support + }); + + header.appendChild(toggleContainer); + header.appendChild(strengthLabel); + container.appendChild(header); + + // Render each lora entry + lorasData.forEach((loraData) => { + const { name, strength, active } = loraData; + + const loraEl = document.createElement("div"); + loraEl.className = "comfy-lora-entry"; + Object.assign(loraEl.style, { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "8px", + borderRadius: "6px", + backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)", + transition: "all 0.2s ease", + marginBottom: "6px", + }); + + // Create toggle for this lora + const toggle = createToggle(active, (newActive) => { + // Update this lora's active state + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + lorasData[loraIndex].active = newActive; + + const newValue = formatLoraValue(lorasData); + widget.value = newValue; + } + }); + + // Create name display + const nameEl = document.createElement("div"); + nameEl.textContent = name; + Object.assign(nameEl.style, { + marginLeft: "10px", + flex: "1", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", + fontSize: "13px", + cursor: "pointer", // Add pointer cursor to indicate hoverable area + userSelect: "none", // Add this line to prevent text selection + WebkitUserSelect: "none", // For Safari support + MozUserSelect: "none", // For Firefox support + msUserSelect: "none", // For IE/Edge support + }); + + // Move preview tooltip events to nameEl instead of loraEl + nameEl.addEventListener('mouseenter', async (e) => { + e.stopPropagation(); + const rect = nameEl.getBoundingClientRect(); + await previewTooltip.show(name, rect.right, rect.top); + }); + + nameEl.addEventListener('mouseleave', (e) => { + e.stopPropagation(); + previewTooltip.hide(); + }); + + // Remove the preview tooltip events from loraEl + loraEl.onmouseenter = () => { + loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)"; + }; + + loraEl.onmouseleave = () => { + loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)"; + }; + + // Add context menu event + loraEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + createContextMenu(e.clientX, e.clientY, name, widget); + }); + + // Create strength control + const strengthControl = document.createElement("div"); + Object.assign(strengthControl.style, { + display: "flex", + alignItems: "center", + gap: "8px", + }); + + // Left arrow + const leftArrow = createArrowButton("left", () => { + // Decrease strength + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2); + + const newValue = formatLoraValue(lorasData); + widget.value = newValue; + } + }); + + // Strength display + const strengthEl = document.createElement("input"); + strengthEl.type = "text"; + strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2); + Object.assign(strengthEl.style, { + minWidth: "50px", + width: "50px", + textAlign: "center", + color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", + fontSize: "13px", + background: "none", + border: "1px solid transparent", + padding: "2px 4px", + borderRadius: "3px", + outline: "none", + }); + + // 添加hover效果 + strengthEl.addEventListener('mouseenter', () => { + strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)"; + }); + + strengthEl.addEventListener('mouseleave', () => { + if (document.activeElement !== strengthEl) { + strengthEl.style.border = "1px solid transparent"; + } + }); + + // 处理焦点 + strengthEl.addEventListener('focus', () => { + strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)"; + strengthEl.style.background = "rgba(0, 0, 0, 0.2)"; + // 自动选中所有内容 + strengthEl.select(); + }); + + strengthEl.addEventListener('blur', () => { + strengthEl.style.border = "1px solid transparent"; + strengthEl.style.background = "none"; + }); + + // 处理输入变化 + strengthEl.addEventListener('change', () => { + let newValue = parseFloat(strengthEl.value); + + // 验证输入 + if (isNaN(newValue)) { + newValue = 1.0; + } + + // 更新数值 + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + lorasData[loraIndex].strength = newValue.toFixed(2); + + // 更新值并触发回调 + const newLorasValue = formatLoraValue(lorasData); + widget.value = newLorasValue; + } + }); + + // 处理按键事件 + strengthEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + strengthEl.blur(); + } + }); + + // Right arrow + const rightArrow = createArrowButton("right", () => { + // Increase strength + const lorasData = parseLoraValue(widget.value); + const loraIndex = lorasData.findIndex(l => l.name === name); + + if (loraIndex >= 0) { + lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2); + + const newValue = formatLoraValue(lorasData); + widget.value = newValue; + } + }); + + strengthControl.appendChild(leftArrow); + strengthControl.appendChild(strengthEl); + strengthControl.appendChild(rightArrow); + + // Assemble entry + const leftSection = document.createElement("div"); + Object.assign(leftSection.style, { + display: "flex", + alignItems: "center", + flex: "1", + minWidth: "0", // Allow shrinking + }); + + leftSection.appendChild(toggle); + leftSection.appendChild(nameEl); + + loraEl.appendChild(leftSection); + loraEl.appendChild(strengthControl); + + container.appendChild(loraEl); + }); + }; + + // Store the value in a variable to avoid recursion + let widgetValue = defaultValue; + + // Create widget with initial properties + const widget = node.addDOMWidget(name, "loras", container, { + getValue: function() { + return widgetValue; + }, + setValue: function(v) { + // Remove duplicates by keeping the last occurrence of each lora name + const uniqueValue = (v || []).reduce((acc, lora) => { + // Remove any existing lora with the same name + const filtered = acc.filter(l => l.name !== lora.name); + // Add the current lora + return [...filtered, lora]; + }, []); + + widgetValue = uniqueValue; + renderLoras(widgetValue, widget); + + // Update container height after rendering + requestAnimationFrame(() => { + const minHeight = this.getMinHeight(); + container.style.height = `${minHeight}px`; + + // Force node to update size + node.setSize([node.size[0], node.computeSize()[1]]); + node.setDirtyCanvas(true, true); + }); + }, + getMinHeight: function() { + // Calculate height based on content + const lorasCount = parseLoraValue(widgetValue).length; + return Math.max( + 100, + lorasCount > 0 ? 60 + lorasCount * 44 : 60 + ); + }, + }); + + widget.value = defaultValue; + + widget.callback = callback; + + widget.serializeValue = () => { + // Add dummy items to avoid the 2-element serialization issue, a bug in comfyui + return [...widgetValue, + { name: "__dummy_item1__", strength: 0, active: false, _isDummy: true }, + { name: "__dummy_item2__", strength: 0, active: false, _isDummy: true } + ]; + } + + widget.onRemove = () => { + container.remove(); + previewTooltip.cleanup(); + }; + + return { minWidth: 400, minHeight: 200, widget }; +} + +// Function to directly save the recipe without dialog +async function saveRecipeDirectly(widget) { + try { + // Get the workflow data from the ComfyUI app + const prompt = await app.graphToPrompt(); + console.log('Prompt:', 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 + }); + } + + // Prepare the data - only send workflow JSON + const formData = new FormData(); + formData.append('workflow_json', JSON.stringify(prompt.output)); + + // Send the request + const response = await fetch('/api/recipes/save-from-widget', { + method: 'POST', + body: formData + }); + + 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 + }); + } + } +} diff --git a/web/comfyui/lora_loader.js b/web/comfyui/lora_loader.js index 815b1a1c..62f01f1c 100644 --- a/web/comfyui/lora_loader.js +++ b/web/comfyui/lora_loader.js @@ -1,9 +1,14 @@ import { app } from "../../scripts/app.js"; -import { addLorasWidget } from "./loras_widget.js"; +import { dynamicImportByVersion } from "./utils.js"; // Extract pattern into a constant for consistent use const LORA_PATTERN = //g; +// Function to get the appropriate loras widget based on ComfyUI version +async function getLorasWidgetModule() { + return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js"); +} + function mergeLoras(lorasText, lorasArr) { const result = []; let match; @@ -44,7 +49,7 @@ app.registerExtension({ }); // Wait for node to be properly initialized - requestAnimationFrame(() => { + requestAnimationFrame(async () => { // Restore saved value if exists let existingLoras = []; if (node.widgets_values && node.widgets_values.length > 0) { @@ -67,6 +72,10 @@ app.registerExtension({ // Add flag to prevent callback loops let isUpdating = false; + + // Dynamically load the appropriate widget module + const lorasModule = await getLorasWidgetModule(); + const { addLorasWidget } = lorasModule; // Get the widget object directly from the returned object const result = addLorasWidget(node, "loras", { diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index 0e6c0129..4e38394e 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -5,6 +5,13 @@ export function addLorasWidget(node, name, opts, callback) { // Create container for loras const container = document.createElement("div"); container.className = "comfy-loras-container"; + + // Set initial height using CSS variables approach + const defaultHeight = 200; + container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`); + container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`); + container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`); + Object.assign(container.style, { display: "flex", flexDirection: "column", @@ -13,11 +20,19 @@ export function addLorasWidget(node, name, opts, callback) { backgroundColor: "rgba(40, 44, 52, 0.6)", borderRadius: "6px", width: "100%", + boxSizing: "border-box", + overflow: "auto" }); // Initialize default value const defaultValue = opts?.defaultVal || []; + // Fixed sizes for component calculations + const LORA_ENTRY_HEIGHT = 44; // Height of a single lora 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 + // Parse LoRA entries from value const parseLoraValue = (value) => { if (!value) return []; @@ -29,6 +44,23 @@ export function addLorasWidget(node, name, opts, callback) { 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"); @@ -107,7 +139,7 @@ export function addLorasWidget(node, name, opts, callback) { return button; }; - // 添加预览弹窗组件 + // Preview tooltip class class PreviewTooltip { constructor() { this.element = document.createElement('div'); @@ -122,31 +154,31 @@ export function addLorasWidget(node, name, opts, callback) { maxWidth: '300px', }); document.body.appendChild(this.element); - this.hideTimeout = null; // 添加超时处理变量 + this.hideTimeout = null; - // 添加全局点击事件来隐藏tooltip + // 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; } - // 如果已经显示同一个lora的预览,则不重复显示 + // Don't redisplay the same lora preview if (this.element.style.display === 'block' && this.currentLora === loraName) { return; } this.currentLora = loraName; - // 获取预览URL + // Get preview URL const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); @@ -160,7 +192,7 @@ export function addLorasWidget(node, name, opts, callback) { throw new Error('No preview available'); } - // 清除现有内容 + // Clear existing content while (this.element.firstChild) { this.element.removeChild(this.element.firstChild); } @@ -217,7 +249,7 @@ export function addLorasWidget(node, name, opts, callback) { 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); @@ -232,20 +264,20 @@ export function addLorasWidget(node, name, opts, callback) { } 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; // 默认在鼠标右侧偏移10px - let top = y + 10; // 默认在鼠标下方偏移10px + 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; } @@ -257,13 +289,13 @@ export function addLorasWidget(node, name, opts, callback) { } 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(); @@ -277,14 +309,14 @@ export function addLorasWidget(node, name, opts, callback) { if (this.hideTimeout) { clearTimeout(this.hideTimeout); } - // 移除所有事件监听器 + // Remove all event listeners document.removeEventListener('click', () => this.hide()); document.removeEventListener('scroll', () => this.hide(), true); this.element.remove(); } } - // 创建预览tooltip实例 + // Create preview tooltip instance const previewTooltip = new PreviewTooltip(); // Function to create menu item @@ -357,7 +389,7 @@ export function addLorasWidget(node, name, opts, callback) { padding: '4px 0', zIndex: 1000, boxShadow: '0 2px 10px rgba(0,0,0,0.2)', - minWidth: '180px', + minWidth: '180px', }); // View on Civitai option with globe icon @@ -447,7 +479,7 @@ export function addLorasWidget(node, name, opts, callback) { borderTop: '1px solid rgba(255, 255, 255, 0.1)', }); - menu.appendChild(viewOnCivitaiOption); // Add the new menu option + menu.appendChild(viewOnCivitaiOption); menu.appendChild(deleteOption); menu.appendChild(separator); menu.appendChild(saveOption); @@ -483,12 +515,16 @@ export function addLorasWidget(node, name, opts, callback) { padding: "20px 0", color: "rgba(226, 232, 240, 0.8)", fontStyle: "italic", - userSelect: "none", // Add this line to prevent text selection - WebkitUserSelect: "none", // For Safari support - MozUserSelect: "none", // For Firefox support - msUserSelect: "none", // For IE/Edge support + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", + width: "100%" }); container.appendChild(emptyMessage); + + // Set fixed height for empty state + updateWidgetHeight(EMPTY_CONTAINER_HEIGHT); return; } @@ -522,10 +558,10 @@ export function addLorasWidget(node, name, opts, callback) { color: "rgba(226, 232, 240, 0.8)", fontSize: "13px", marginLeft: "8px", - userSelect: "none", // Add this line to prevent text selection - WebkitUserSelect: "none", // For Safari support - MozUserSelect: "none", // For Firefox support - msUserSelect: "none", // For IE/Edge support + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", }); const toggleContainer = document.createElement("div"); @@ -543,10 +579,10 @@ export function addLorasWidget(node, name, opts, callback) { color: "rgba(226, 232, 240, 0.8)", fontSize: "13px", marginRight: "8px", - userSelect: "none", // Add this line to prevent text selection - WebkitUserSelect: "none", // For Safari support - MozUserSelect: "none", // For Firefox support - msUserSelect: "none", // For IE/Edge support + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", }); header.appendChild(toggleContainer); @@ -595,11 +631,11 @@ export function addLorasWidget(node, name, opts, callback) { whiteSpace: "nowrap", color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", fontSize: "13px", - cursor: "pointer", // Add pointer cursor to indicate hoverable area - userSelect: "none", // Add this line to prevent text selection - WebkitUserSelect: "none", // For Safari support - MozUserSelect: "none", // For Firefox support - msUserSelect: "none", // For IE/Edge support + cursor: "pointer", + userSelect: "none", + WebkitUserSelect: "none", + MozUserSelect: "none", + msUserSelect: "none", }); // Move preview tooltip events to nameEl instead of loraEl @@ -645,7 +681,7 @@ export function addLorasWidget(node, name, opts, callback) { const loraIndex = lorasData.findIndex(l => l.name === name); if (loraIndex >= 0) { - lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2); + lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2); const newValue = formatLoraValue(lorasData); widget.value = newValue; @@ -669,7 +705,7 @@ export function addLorasWidget(node, name, opts, callback) { outline: "none", }); - // 添加hover效果 + // Add hover effect strengthEl.addEventListener('mouseenter', () => { strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)"; }); @@ -680,11 +716,11 @@ export function addLorasWidget(node, name, opts, callback) { } }); - // 处理焦点 + // Handle focus strengthEl.addEventListener('focus', () => { strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)"; strengthEl.style.background = "rgba(0, 0, 0, 0.2)"; - // 自动选中所有内容 + // Auto-select all content strengthEl.select(); }); @@ -693,29 +729,29 @@ export function addLorasWidget(node, name, opts, callback) { strengthEl.style.background = "none"; }); - // 处理输入变化 + // Handle input changes strengthEl.addEventListener('change', () => { let newValue = parseFloat(strengthEl.value); - // 验证输入 + // Validate input if (isNaN(newValue)) { newValue = 1.0; } - // 更新数值 + // Update value const lorasData = parseLoraValue(widget.value); const loraIndex = lorasData.findIndex(l => l.name === name); if (loraIndex >= 0) { lorasData[loraIndex].strength = newValue.toFixed(2); - // 更新值并触发回调 + // Update value and trigger callback const newLorasValue = formatLoraValue(lorasData); widget.value = newLorasValue; } }); - // 处理按键事件 + // Handle key events strengthEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { strengthEl.blur(); @@ -757,13 +793,17 @@ export function addLorasWidget(node, name, opts, callback) { container.appendChild(loraEl); }); + + // Calculate height based on number of loras and fixed sizes + const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (lorasData.length * LORA_ENTRY_HEIGHT); + updateWidgetHeight(calculatedHeight); }; // Store the value in a variable to avoid recursion let widgetValue = defaultValue; - // Create widget with initial properties - const widget = node.addDOMWidget(name, "loras", container, { + // Create widget with new DOM Widget API + const widget = node.addDOMWidget(name, "custom", container, { getValue: function() { return widgetValue; }, @@ -778,29 +818,28 @@ export function addLorasWidget(node, name, opts, callback) { widgetValue = uniqueValue; renderLoras(widgetValue, widget); - - // Update container height after rendering - requestAnimationFrame(() => { - const minHeight = this.getMinHeight(); - container.style.height = `${minHeight}px`; - - // Force node to update size - node.setSize([node.size[0], node.computeSize()[1]]); - node.setDirtyCanvas(true, true); - }); }, getMinHeight: function() { - // Calculate height based on content - const lorasCount = parseLoraValue(widgetValue).length; - return Math.max( - 100, - lorasCount > 0 ? 60 + lorasCount * 44 : 60 - ); + return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight; }, + getMaxHeight: function() { + return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2; + }, + getHeight: function() { + return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight; + }, + hideOnZoom: true, + selectOn: ['click', 'focus'], + afterResize: function(node) { + // Re-render after node resize + if (this.value && this.value.length > 0) { + renderLoras(this.value, this); + } + } }); widget.value = defaultValue; - + widget.callback = callback; widget.serializeValue = () => { @@ -816,7 +855,7 @@ export function addLorasWidget(node, name, opts, callback) { previewTooltip.cleanup(); }; - return { minWidth: 400, minHeight: 200, widget }; + return { minWidth: 400, minHeight: defaultHeight, widget }; } // Function to directly save the recipe without dialog @@ -824,7 +863,6 @@ async function saveRecipeDirectly(widget) { try { // Get the workflow data from the ComfyUI app const prompt = await app.graphToPrompt(); - console.log('Prompt:', prompt); // Show loading toast if (app && app.extensionManager && app.extensionManager.toast) { @@ -879,4 +917,4 @@ async function saveRecipeDirectly(widget) { }); } } -} +} \ No newline at end of file diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js index 3b61da84..f33ec919 100644 --- a/web/comfyui/trigger_word_toggle.js +++ b/web/comfyui/trigger_word_toggle.js @@ -1,34 +1,10 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +import { CONVERTED_TYPE, dynamicImportByVersion } from "./utils.js"; -const CONVERTED_TYPE = 'converted-widget' - -function getComfyUIFrontendVersion() { - // 直接访问全局变量 - return window['__COMFYUI_FRONTEND_VERSION__']; -} - -// Dynamically import the appropriate tags widget based on app version -function getTagsWidgetModule() { - // Parse app version and compare with 1.12.6 - const currentVersion = getComfyUIFrontendVersion() || "0.0.0"; - console.log("currentVersion", currentVersion); - const versionParts = currentVersion.split('.').map(part => parseInt(part, 10)); - const requiredVersion = [1, 12, 6]; - - // Compare version numbers - for (let i = 0; i < 3; i++) { - if (versionParts[i] > requiredVersion[i]) { - console.log("Using tags_widget.js"); - return import("./tags_widget.js"); - } else if (versionParts[i] < requiredVersion[i]) { - console.log("Using legacy_tags_widget.js"); - return import("./legacy_tags_widget.js"); - } - } - - // If we get here, versions are equal, use the new module - return import("./tags_widget.js"); +// Function to get the appropriate tags widget based on ComfyUI version +async function getTagsWidgetModule() { + return await dynamicImportByVersion("./tags_widget.js", "./legacy_tags_widget.js"); } // TriggerWordToggle extension for ComfyUI @@ -55,8 +31,8 @@ app.registerExtension({ // Wait for node to be properly initialized requestAnimationFrame(async () => { // Dynamically import the appropriate tags widget module - const tagsWidgetModule = await getTagsWidgetModule(); - const { addTagsWidget } = tagsWidgetModule; + const tagsModule = await getTagsWidgetModule(); + const { addTagsWidget } = tagsModule; // Get the widget object directly from the returned object const result = addTagsWidget(node, "toggle_trigger_words", { diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index d7252a85..8d9c1f6c 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -1,5 +1,32 @@ export const CONVERTED_TYPE = 'converted-widget'; +export function getComfyUIFrontendVersion() { + return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0"; +} + +// Dynamically import the appropriate widget based on app version +export async function dynamicImportByVersion(latestModulePath, legacyModulePath) { + // Parse app version and compare with 1.12.6 (version when tags widget API changed) + const currentVersion = getComfyUIFrontendVersion(); + const versionParts = currentVersion.split('.').map(part => parseInt(part, 10)); + const requiredVersion = [1, 12, 6]; + + // Compare version numbers + for (let i = 0; i < 3; i++) { + if (versionParts[i] > requiredVersion[i]) { + console.log(`Using latest widget: ${latestModulePath}`); + return import(latestModulePath); + } else if (versionParts[i] < requiredVersion[i]) { + console.log(`Using legacy widget: ${legacyModulePath}`); + return import(legacyModulePath); + } + } + + // If we get here, versions are equal, use the latest module + console.log(`Using latest widget: ${latestModulePath}`); + return import(latestModulePath); +} + export function hideWidgetForGood(node, widget, suffix = "") { widget.origType = widget.type; widget.origComputeSize = widget.computeSize;