Files
ComfyUI-Lora-Manager/web/comfyui/loras_widget_events.js
Will Miao fad43ad003 feat(ui): restrict drag events to left mouse button only, fixes #777
Add button condition checks in initDrag and initHeaderDrag functions to ensure only left mouse button (button 0) triggers drag interactions. This prevents conflicts with middle button canvas dragging and right button context menu actions, improving user experience and interaction clarity.
2026-01-24 22:26:17 +08:00

961 lines
32 KiB
JavaScript

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;
let hasMoved = false;
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-lock-button') ||
e.target.closest('.lm-lora-expand-button')) {
return;
}
// Only handle left mouse button (allow middle button for canvas drag, right button for context menu)
if (e.button !== 0) {
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;
hasMoved = false;
activePointerId = e.pointerId;
currentDragElement = e.currentTarget;
// Capture pointer to receive all subsequent events regardless of stopPropagation
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
// Prevent text selection
e.preventDefault();
});
dragEl.addEventListener('pointermove', (e) => {
if (!isDragging) return;
// Track if pointer moved significantly (more than 3 pixels)
if (Math.abs(e.clientX - initialX) > 3) {
hasMoved = true;
}
// Only stop propagation if we've started dragging (moved beyond threshold)
if (hasMoved) {
e.stopPropagation();
}
// Only process drag if we've moved beyond threshold
if (!hasMoved) return;
// Add class to body to enforce cursor style globally only after drag starts
document.body.classList.add('lm-lora-strength-dragging');
if (typeof onDragStart === 'function') {
onDragStart();
}
// 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 = Number(strengthValue).toFixed(2);
}
}
// Prevent showing the preview tooltip during drag
if (previewTooltip) {
previewTooltip.hide();
}
});
const endDrag = (e) => {
if (!isDragging) return;
// Only stop propagation if we actually dragged
if (hasMoved) {
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
}
}
const wasDragging = hasMoved;
isDragging = false;
hasMoved = false;
activePointerId = null;
currentDragElement = null;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('lm-lora-strength-dragging');
// Only call onDragEnd and re-render if we actually dragged
if (wasDragging) {
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;
let hasMoved = false;
// 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;
}
// Only handle left mouse button (allow middle button for canvas drag, right button for context menu)
if (e.button !== 0) {
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;
hasMoved = false;
activePointerId = e.pointerId;
currentHeaderElement = e.currentTarget;
// Capture pointer to receive all subsequent events regardless of stopPropagation
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
// Prevent text selection
e.preventDefault();
});
// Handle pointer move for dragging
headerEl.addEventListener('pointermove', (e) => {
if (!isDragging) return;
// Track if pointer moved significantly (more than 3 pixels)
if (Math.abs(e.clientX - initialX) > 3) {
hasMoved = true;
}
// Only stop propagation if we've started dragging (moved beyond threshold)
if (hasMoved) {
e.stopPropagation();
}
// Only process drag if we've moved beyond threshold
if (!hasMoved) return;
// Add class to body to enforce cursor style globally only after drag starts
document.body.classList.add('lm-lora-strength-dragging');
// 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;
// Only stop propagation if we actually dragged
if (hasMoved) {
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
}
}
const wasDragging = hasMoved;
isDragging = false;
hasMoved = false;
activePointerId = null;
currentHeaderElement = null;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('lm-lora-strength-dragging');
// Only re-render if we actually dragged
if (wasDragging && 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;
let hasMoved = false;
dragHandle.addEventListener('pointerdown', (e) => {
e.preventDefault();
isDragging = true;
hasMoved = false;
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);
});
dragHandle.addEventListener('pointermove', (e) => {
if (!isDragging || !draggedElement) return;
// Track if pointer moved significantly (more than 3 pixels vertically)
if (Math.abs(e.movementY) > 3) {
hasMoved = true;
}
// Only stop propagation and process drag if we've moved beyond threshold
if (!hasMoved) return;
// Stop propagation and start drag visuals
e.stopPropagation();
// Add dragging class and visual feedback only after drag starts
if (!dropIndicator) {
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;
}
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) => {
// Only stop propagation if we actually dragged
if (hasMoved) {
e.stopPropagation();
}
// Always reset cursor regardless of isDragging state
document.body.classList.remove('lm-lora-reordering');
if (!isDragging || !draggedElement) {
// Release pointer capture even if not dragging
const target = e.currentTarget;
if (activePointerId !== null) {
target.releasePointerCapture(activePointerId);
}
isDragging = false;
hasMoved = false;
activePointerId = null;
return;
}
// Release pointer capture
const target = e.currentTarget;
if (activePointerId !== null) {
target.releasePointerCapture(activePointerId);
}
const wasDragging = hasMoved;
isDragging = false;
hasMoved = false;
activePointerId = null;
// Only process reordering if we actually dragged
if (wasDragging) {
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
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) => {
// Only stop propagation if we actually dragged
if (hasMoved) {
e.stopPropagation();
}
// Always reset cursor regardless of isDragging state
document.body.classList.remove('lm-lora-reordering');
if (!isDragging || !draggedElement) {
// Release pointer capture even if not dragging
const target = e.currentTarget;
if (activePointerId !== null) {
target.releasePointerCapture(activePointerId);
}
isDragging = false;
hasMoved = false;
activePointerId = null;
return;
}
// Release pointer capture
const target = e.currentTarget;
if (activePointerId !== null) {
target.releasePointerCapture(activePointerId);
}
isDragging = false;
hasMoved = false;
activePointerId = null;
// Cleanup without reordering
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>',
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18m-2 0v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>',
() => {
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>',
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7.01" y2="7"></line></svg>',
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
saveRecipeDirectly();
}
);
// Move Up option with arrow up icon
const moveUpOption = createMenuItem(
'Move Up',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"></path></svg>',
() => {
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"></path></svg>',
() => {
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 11l-5-5-5 5M17 18l-5-5-5 5"></path></svg>',
() => {
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',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"></path></svg>',
() => {
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);
}