import { api } from "../../scripts/api.js"; import { app } from "../../scripts/app.js"; import { createMenuItem, createDropIndicator } from "./loras_widget_components.js"; import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex } from "./loras_widget_utils.js"; // Function to handle strength adjustment via dragging export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false, updateWidget = true) { // 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 only if updateWidget flag is true // This allows us to update inputs directly during drag without triggering re-render if (updateWidget) { widget.value = formatLoraValue(lorasData); } // Force re-render via callback only if updateWidget is true if (updateWidget && widget.callback) { widget.callback(widget.value); } } } // Function to handle proportional strength adjustment for all LoRAs via header dragging export function handleAllStrengthsDrag(initialStrengths, initialX, event, widget, updateWidget = true) { // Define sensitivity (less sensitive than individual adjustment) const sensitivity = 0.0005; // Get current mouse position const currentX = event.clientX; // Calculate the distance moved const deltaX = currentX - initialX; // Calculate adjustment factor (1.0 means no change, >1.0 means increase, <1.0 means decrease) // For positive deltaX, we want to increase strengths, for negative we want to decrease const adjustmentFactor = 1.0 + (deltaX * sensitivity); // Ensure adjustment factor is reasonable (prevent extreme changes) const limitedFactor = Math.max(0.01, Math.min(3.0, adjustmentFactor)); // Get current loras data const lorasData = parseLoraValue(widget.value); // Apply the adjustment factor to each LoRA's strengths lorasData.forEach((loraData, index) => { // Get initial strengths for this LoRA const initialModelStrength = initialStrengths[index].modelStrength; const initialClipStrength = initialStrengths[index].clipStrength; // Apply the adjustment factor to both strengths let newModelStrength = (initialModelStrength * limitedFactor).toFixed(2); let newClipStrength = (initialClipStrength * limitedFactor).toFixed(2); // Limit the values to reasonable bounds (-10 to 10) newModelStrength = Math.max(-10, Math.min(10, newModelStrength)); newClipStrength = Math.max(-10, Math.min(10, newClipStrength)); // Update strengths lorasData[index].strength = Number(newModelStrength); lorasData[index].clipStrength = Number(newClipStrength); }); // Update widget value only if updateWidget flag is true if (updateWidget) { widget.value = formatLoraValue(lorasData); } // Force re-render via callback only if updateWidget is true if (updateWidget && widget.callback) { widget.callback(widget.value); } } // Function to initialize drag operation export function initDrag( dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction, dragCallbacks = {} ) { let isDragging = false; let activePointerId = null; let initialX = 0; let initialStrength = 0; let currentDragElement = null; const { onDragStart, onDragEnd } = dragCallbacks; // Create a drag handler using pointer events for Vue DOM render mode compatibility dragEl.addEventListener('pointerdown', (e) => { // Skip if clicking on toggle or strength control areas if (e.target.closest('.lm-lora-toggle') || e.target.closest('input') || e.target.closest('.lm-lora-arrow') || e.target.closest('.lm-lora-drag-handle') || e.target.closest('.lm-lora-expand-button')) { 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; activePointerId = e.pointerId; currentDragElement = e.currentTarget; // Capture pointer to receive all subsequent events regardless of stopPropagation const target = e.currentTarget; target.setPointerCapture(e.pointerId); // Add class to body to enforce cursor style globally document.body.classList.add('lm-lora-strength-dragging'); if (typeof onDragStart === 'function') { onDragStart(); } // Prevent text selection and default behavior e.preventDefault(); e.stopPropagation(); }); dragEl.addEventListener('pointermove', (e) => { if (!isDragging) return; // Stop propagation to prevent canvas from interfering e.stopPropagation(); // Call the strength adjustment function without updating widget.value during drag handleStrengthDrag(name, initialStrength, initialX, e, widget, isClipStrength, false); // Update strength input directly instead of re-rendering to avoid losing event listeners const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input'); if (strengthInput) { const lorasData = parseLoraValue(widget.value); const loraData = lorasData.find(l => l.name === name); if (loraData) { const strengthValue = isClipStrength ? loraData.clipStrength : loraData.strength; strengthInput.value = strengthValue; } } // Prevent showing the preview tooltip during drag if (previewTooltip) { previewTooltip.hide(); } }); const endDrag = (e) => { if (!isDragging) return; e.stopPropagation(); // Release pointer capture if still have the element if (currentDragElement && activePointerId !== null) { try { currentDragElement.releasePointerCapture(activePointerId); } catch (err) { // Ignore errors if element is no longer in DOM } } isDragging = false; activePointerId = null; currentDragElement = null; // Remove the class to restore normal cursor behavior document.body.classList.remove('lm-lora-strength-dragging'); if (typeof onDragEnd === 'function') { onDragEnd(); } // Now do the re-render after drag is complete if (renderFunction) { renderFunction(widget.value, widget); } }; dragEl.addEventListener('pointerup', endDrag); dragEl.addEventListener('pointercancel', endDrag); } // Function to initialize header drag for proportional strength adjustment export function initHeaderDrag(headerEl, widget, renderFunction) { let isDragging = false; let activePointerId = null; let initialX = 0; let initialStrengths = []; let currentHeaderElement = null; // Add cursor style to indicate draggable // Create a drag handler using pointer events for Vue DOM render mode compatibility headerEl.addEventListener('pointerdown', (e) => { // Skip if clicking on toggle or other interactive elements if (e.target.closest('.lm-lora-toggle') || e.target.closest('input')) { return; } // Store initial X position initialX = e.clientX; // Store initial strengths of all LoRAs const lorasData = parseLoraValue(widget.value); initialStrengths = lorasData.map(lora => ({ modelStrength: Number(lora.strength), clipStrength: Number(lora.clipStrength) })); isDragging = true; activePointerId = e.pointerId; currentHeaderElement = e.currentTarget; // Capture pointer to receive all subsequent events regardless of stopPropagation const target = e.currentTarget; target.setPointerCapture(e.pointerId); // Add class to body to enforce cursor style globally document.body.classList.add('lm-lora-strength-dragging'); // Prevent text selection and default behavior e.preventDefault(); e.stopPropagation(); }); // Handle pointer move for dragging headerEl.addEventListener('pointermove', (e) => { if (!isDragging) return; e.stopPropagation(); // Call the strength adjustment function without updating widget.value during drag handleAllStrengthsDrag(initialStrengths, initialX, e, widget, false); // Update strength inputs directly instead of re-rendering to avoid losing event listeners const strengthInputs = currentHeaderElement.parentElement.querySelectorAll('.lm-lora-strength-input'); const lorasData = parseLoraValue(widget.value); strengthInputs.forEach((input, index) => { if (lorasData[index]) { input.value = lorasData[index].strength.toFixed(2); } }); }); const endDrag = (e) => { if (!isDragging) return; e.stopPropagation(); // Release pointer capture if still have the element if (currentHeaderElement && activePointerId !== null) { try { currentHeaderElement.releasePointerCapture(activePointerId); } catch (err) { // Ignore errors if element is no longer in DOM } } isDragging = false; activePointerId = null; currentHeaderElement = null; // Remove the class to restore normal cursor behavior document.body.classList.remove('lm-lora-strength-dragging'); // Now do a re-render after drag is complete if (renderFunction) { renderFunction(widget.value, widget); } }; // Handle pointer up to end dragging headerEl.addEventListener('pointerup', endDrag); // Handle pointer cancel to end dragging headerEl.addEventListener('pointercancel', endDrag); } // Function to initialize drag-and-drop for reordering export function initReorderDrag(dragHandle, loraName, widget, renderFunction) { let isDragging = false; let activePointerId = null; let draggedElement = null; let dropIndicator = null; let container = null; let scale = 1; dragHandle.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); isDragging = true; activePointerId = e.pointerId; draggedElement = dragHandle.closest('.lm-lora-entry'); container = draggedElement.parentElement; // Capture pointer to receive all subsequent events regardless of stopPropagation const target = e.currentTarget; target.setPointerCapture(e.pointerId); // Add dragging class and visual feedback draggedElement.classList.add('lm-lora-entry--dragging'); // Create single drop indicator with absolute positioning dropIndicator = createDropIndicator(); // Make container relatively positioned for absolute indicator const originalPosition = container.style.position; container.style.position = 'relative'; container.appendChild(dropIndicator); // Store original position for cleanup container._originalPosition = originalPosition; // Add global cursor style document.body.classList.add('lm-lora-reordering'); // Store workflow scale for accurate positioning scale = app.canvas.ds.scale; }); dragHandle.addEventListener('pointermove', (e) => { if (!isDragging || !draggedElement || !dropIndicator) return; e.stopPropagation(); const targetIndex = getDropTargetIndex(container, e.clientY); const entries = container.querySelectorAll('.lm-lora-entry, .lm-lora-clip-entry'); if (targetIndex === 0) { // Show at top const firstEntry = entries[0]; if (firstEntry) { const rect = firstEntry.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; dropIndicator.style.opacity = '1'; } } else if (targetIndex < entries.length) { // Show between entries const targetEntry = entries[targetIndex]; if (targetEntry) { const rect = targetEntry.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; dropIndicator.style.opacity = '1'; } } else { // Show at bottom const lastEntry = entries[entries.length - 1]; if (lastEntry) { const rect = lastEntry.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`; dropIndicator.style.opacity = '1'; } } }); dragHandle.addEventListener('pointerup', (e) => { e.stopPropagation(); // Always reset cursor regardless of isDragging state document.body.classList.remove('lm-lora-reordering'); if (!isDragging || !draggedElement) return; // Release pointer capture const target = e.currentTarget; if (activePointerId !== null) { target.releasePointerCapture(activePointerId); } const targetIndex = getDropTargetIndex(container, e.clientY); // Get current LoRA data const lorasData = parseLoraValue(widget.value); const currentIndex = lorasData.findIndex(l => l.name === loraName); if (currentIndex !== -1 && currentIndex !== targetIndex) { // Calculate actual target index (excluding clip entries from count) const loraEntries = container.querySelectorAll('.lm-lora-entry'); let actualTargetIndex = targetIndex; // Adjust target index if it's beyond the number of actual LoRA entries if (actualTargetIndex > loraEntries.length) { actualTargetIndex = loraEntries.length; } // Move the LoRA const newLoras = [...lorasData]; const [moved] = newLoras.splice(currentIndex, 1); newLoras.splice(actualTargetIndex > currentIndex ? actualTargetIndex - 1 : actualTargetIndex, 0, moved); widget.value = formatLoraValue(newLoras); if (widget.callback) { widget.callback(widget.value); } // Re-render if (renderFunction) { renderFunction(widget.value, widget); } } // Cleanup isDragging = false; activePointerId = null; if (draggedElement) { draggedElement.classList.remove('lm-lora-entry--dragging'); draggedElement = null; } if (dropIndicator && container) { container.removeChild(dropIndicator); // Restore original position container.style.position = container._originalPosition || ''; delete container._originalPosition; dropIndicator = null; } container = null; }); dragHandle.addEventListener('pointercancel', (e) => { e.stopPropagation(); // Always reset cursor regardless of isDragging state document.body.classList.remove('lm-lora-reordering'); if (!isDragging || !draggedElement) return; // Release pointer capture const target = e.currentTarget; if (activePointerId !== null) { target.releasePointerCapture(activePointerId); } // Cleanup without reordering isDragging = false; activePointerId = null; if (draggedElement) { draggedElement.classList.remove('lm-lora-entry--dragging'); draggedElement = null; } if (dropIndicator && container) { container.removeChild(dropIndicator); // Restore original position container.style.position = container._originalPosition || ''; delete container._originalPosition; dropIndicator = null; } container = null; }); } // Function to handle keyboard navigation export function handleKeyboardNavigation(event, selectedLora, widget, renderFunction, selectLora) { if (!selectedLora) return false; const lorasData = parseLoraValue(widget.value); let handled = false; const isStrengthInputFocused = event?.target?.classList?.contains('lm-lora-strength-input') ?? false; // Check for Ctrl/Cmd modifier for reordering if (event.ctrlKey || event.metaKey) { switch (event.key) { case 'ArrowUp': event.preventDefault(); const newLorasUp = moveLoraByDirection(lorasData, selectedLora, 'up'); widget.value = formatLoraValue(newLorasUp); if (widget.callback) widget.callback(widget.value); if (renderFunction) renderFunction(widget.value, widget); handled = true; break; case 'ArrowDown': event.preventDefault(); const newLorasDown = moveLoraByDirection(lorasData, selectedLora, 'down'); widget.value = formatLoraValue(newLorasDown); if (widget.callback) widget.callback(widget.value); if (renderFunction) renderFunction(widget.value, widget); handled = true; break; case 'Home': event.preventDefault(); const newLorasTop = moveLoraByDirection(lorasData, selectedLora, 'top'); widget.value = formatLoraValue(newLorasTop); if (widget.callback) widget.callback(widget.value); if (renderFunction) renderFunction(widget.value, widget); handled = true; break; case 'End': event.preventDefault(); const newLorasBottom = moveLoraByDirection(lorasData, selectedLora, 'bottom'); widget.value = formatLoraValue(newLorasBottom); if (widget.callback) widget.callback(widget.value); if (renderFunction) renderFunction(widget.value, widget); handled = true; break; } } else { // Normal navigation without Ctrl/Cmd switch (event.key) { case 'ArrowUp': event.preventDefault(); const currentIndex = lorasData.findIndex(l => l.name === selectedLora); if (currentIndex > 0) { selectLora(lorasData[currentIndex - 1].name); } handled = true; break; case 'ArrowDown': event.preventDefault(); const currentIndexDown = lorasData.findIndex(l => l.name === selectedLora); if (currentIndexDown < lorasData.length - 1) { selectLora(lorasData[currentIndexDown + 1].name); } handled = true; break; case 'Delete': case 'Backspace': if (isStrengthInputFocused) { break; } event.preventDefault(); const filtered = lorasData.filter(l => l.name !== selectedLora); widget.value = formatLoraValue(filtered); if (widget.callback) widget.callback(widget.value); if (renderFunction) renderFunction(widget.value, widget); selectLora(null); // Clear selection handled = true; break; } } return handled; } // 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('.lm-lora-context-menu'); if (existingMenu) { existingMenu.remove(); } const menu = document.createElement('div'); menu.className = 'lm-lora-context-menu'; menu.style.left = `${x}px`; menu.style.top = `${y}px`; // 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(`/lm/loras/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 showToast('This LoRA has no associated Civitai URL', 'warning'); } } catch (error) { console.error('Error getting Civitai URL:', error); showToast(error.message || 'Failed to get Civitai URL', 'error'); } } ); // 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); } } ); // New option: Copy Notes with note icon const copyNotesOption = createMenuItem( 'Copy Notes', '', async () => { menu.remove(); document.removeEventListener('click', closeMenu); try { // Get notes from API const response = await api.fetchApi(`/lm/loras/get-notes?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to get notes'); } const data = await response.json(); if (data.success) { const notes = data.notes || ''; if (notes.trim()) { await copyToClipboard(notes, 'Notes copied to clipboard'); } else { showToast('No notes available for this LoRA', 'info'); } } else { throw new Error(data.error || 'Failed to get notes'); } } catch (error) { console.error('Error getting notes:', error); showToast(error.message || 'Failed to get notes', 'error'); } } ); // New option: Copy Trigger Words with tag icon const copyTriggerWordsOption = createMenuItem( 'Copy Trigger Words', '', async () => { menu.remove(); document.removeEventListener('click', closeMenu); try { // Get trigger words from API const response = await api.fetchApi(`/lm/loras/get-trigger-words?name=${encodeURIComponent(loraName)}`, { method: 'GET' }); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || 'Failed to get trigger words'); } const data = await response.json(); if (data.success) { const triggerWords = data.trigger_words || []; if (triggerWords.length > 0) { // Join trigger words with commas const triggerWordsText = triggerWords.join(', '); await copyToClipboard(triggerWordsText, 'Trigger words copied to clipboard'); } else { showToast('No trigger words available for this LoRA', 'info'); } } else { throw new Error(data.error || 'Failed to get trigger words'); } } catch (error) { console.error('Error getting trigger words:', error); showToast(error.message || 'Failed to get trigger words', 'error'); } } ); // Save recipe option with bookmark icon const saveOption = createMenuItem( 'Save Recipe', '', () => { menu.remove(); document.removeEventListener('click', closeMenu); saveRecipeDirectly(); } ); // Move Up option with arrow up icon const moveUpOption = createMenuItem( 'Move Up', '', () => { menu.remove(); document.removeEventListener('click', closeMenu); const lorasData = parseLoraValue(widget.value); const newLoras = moveLoraByDirection(lorasData, loraName, 'up'); widget.value = formatLoraValue(newLoras); if (widget.callback) { widget.callback(widget.value); } if (renderFunction) { renderFunction(widget.value, widget); } } ); // Move Down option with arrow down icon const moveDownOption = createMenuItem( 'Move Down', '', () => { menu.remove(); document.removeEventListener('click', closeMenu); const lorasData = parseLoraValue(widget.value); const newLoras = moveLoraByDirection(lorasData, loraName, 'down'); widget.value = formatLoraValue(newLoras); if (widget.callback) { widget.callback(widget.value); } if (renderFunction) { renderFunction(widget.value, widget); } } ); // Move to Top option with chevrons up icon const moveTopOption = createMenuItem( 'Move to Top', '', () => { menu.remove(); document.removeEventListener('click', closeMenu); const lorasData = parseLoraValue(widget.value); const newLoras = moveLoraByDirection(lorasData, loraName, 'top'); widget.value = formatLoraValue(newLoras); if (widget.callback) { widget.callback(widget.value); } if (renderFunction) { renderFunction(widget.value, widget); } } ); // Move to Bottom option with chevrons down icon const moveBottomOption = createMenuItem( 'Move to Bottom', '', () => { menu.remove(); document.removeEventListener('click', closeMenu); const lorasData = parseLoraValue(widget.value); const newLoras = moveLoraByDirection(lorasData, loraName, 'bottom'); widget.value = formatLoraValue(newLoras); if (widget.callback) { widget.callback(widget.value); } if (renderFunction) { renderFunction(widget.value, widget); } } ); // Add separator const separator1 = document.createElement('hr'); // Add second separator const separator2 = document.createElement('hr'); // Add separator for order options const orderSeparator = document.createElement('hr'); menu.appendChild(viewOnCivitaiOption); menu.appendChild(deleteOption); menu.appendChild(separator1); menu.appendChild(moveUpOption); menu.appendChild(moveDownOption); menu.appendChild(moveTopOption); menu.appendChild(moveBottomOption); menu.appendChild(orderSeparator); menu.appendChild(copyNotesOption); menu.appendChild(copyTriggerWordsOption); menu.appendChild(separator2); 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); }