mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
462 lines
15 KiB
JavaScript
462 lines
15 KiB
JavaScript
import { state } from '../state/index.js';
|
||
import { resetAndReload } from '../api/loraApi.js';
|
||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||
import { NSFW_LEVELS } from './constants.js';
|
||
|
||
/**
|
||
* Utility function to copy text to clipboard with fallback for older browsers
|
||
* @param {string} text - The text to copy to clipboard
|
||
* @param {string} successMessage - Optional success message to show in toast
|
||
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
|
||
*/
|
||
export async function copyToClipboard(text, successMessage = 'Copied to clipboard') {
|
||
try {
|
||
// Modern clipboard API
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text);
|
||
} else {
|
||
// Fallback for older browsers
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'absolute';
|
||
textarea.style.left = '-99999px';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
}
|
||
|
||
if (successMessage) {
|
||
showToast(successMessage, 'success');
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.error('Copy failed:', err);
|
||
showToast('Copy failed', 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function showToast(message, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message;
|
||
|
||
// Get or create toast container
|
||
let toastContainer = document.querySelector('.toast-container');
|
||
if (!toastContainer) {
|
||
toastContainer = document.createElement('div');
|
||
toastContainer.className = 'toast-container';
|
||
document.body.append(toastContainer);
|
||
}
|
||
|
||
toastContainer.append(toast);
|
||
|
||
// Calculate vertical position for stacked toasts
|
||
const existingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||
const toastIndex = existingToasts.indexOf(toast);
|
||
const topOffset = 20; // Base offset from top
|
||
const spacing = 10; // Space between toasts
|
||
|
||
// Set position based on existing toasts
|
||
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
||
|
||
requestAnimationFrame(() => {
|
||
toast.classList.add('show');
|
||
|
||
// Set timeout based on type
|
||
let timeout = 2000; // Default (info)
|
||
if (type === 'warning' || type === 'error') {
|
||
timeout = 5000;
|
||
}
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
toast.addEventListener('transitionend', () => {
|
||
toast.remove();
|
||
|
||
// Reposition remaining toasts
|
||
if (toastContainer) {
|
||
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||
remainingToasts.forEach((t, index) => {
|
||
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||
});
|
||
|
||
// Remove container if empty
|
||
if (remainingToasts.length === 0) {
|
||
toastContainer.remove();
|
||
}
|
||
}
|
||
});
|
||
}, timeout);
|
||
});
|
||
}
|
||
|
||
export function restoreFolderFilter() {
|
||
const activeFolder = getStorageItem('activeFolder');
|
||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||
if (folderTag) {
|
||
folderTag.classList.add('active');
|
||
filterByFolder(activeFolder);
|
||
}
|
||
}
|
||
|
||
export function initTheme() {
|
||
const savedTheme = getStorageItem('theme') || 'auto';
|
||
applyTheme(savedTheme);
|
||
|
||
// Update theme when system preference changes (for 'auto' mode)
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||
const currentTheme = getStorageItem('theme') || 'auto';
|
||
if (currentTheme === 'auto') {
|
||
applyTheme('auto');
|
||
}
|
||
});
|
||
}
|
||
|
||
export function toggleTheme() {
|
||
const currentTheme = getStorageItem('theme') || 'auto';
|
||
let newTheme;
|
||
|
||
// Cycle through themes: light → dark → auto → light
|
||
if (currentTheme === 'light') {
|
||
newTheme = 'dark';
|
||
} else if (currentTheme === 'dark') {
|
||
newTheme = 'auto';
|
||
} else {
|
||
newTheme = 'light';
|
||
}
|
||
|
||
setStorageItem('theme', newTheme);
|
||
applyTheme(newTheme);
|
||
|
||
// Force a repaint to ensure theme changes are applied immediately
|
||
document.body.style.display = 'none';
|
||
document.body.offsetHeight; // Trigger a reflow
|
||
document.body.style.display = '';
|
||
|
||
return newTheme;
|
||
}
|
||
|
||
// Add a new helper function to apply the theme
|
||
function applyTheme(theme) {
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
const htmlElement = document.documentElement;
|
||
|
||
// Remove any existing theme attributes
|
||
htmlElement.removeAttribute('data-theme');
|
||
|
||
// Apply the appropriate theme
|
||
if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
|
||
htmlElement.setAttribute('data-theme', 'dark');
|
||
document.body.dataset.theme = 'dark';
|
||
} else {
|
||
htmlElement.setAttribute('data-theme', 'light');
|
||
document.body.dataset.theme = 'light';
|
||
}
|
||
|
||
// Update the theme-toggle icon state
|
||
updateThemeToggleIcons(theme);
|
||
}
|
||
|
||
// New function to update theme toggle icons
|
||
function updateThemeToggleIcons(theme) {
|
||
const themeToggle = document.querySelector('.theme-toggle');
|
||
if (!themeToggle) return;
|
||
|
||
// Remove any existing active classes
|
||
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||
|
||
// Add the appropriate class based on current theme
|
||
themeToggle.classList.add(`theme-${theme}`);
|
||
}
|
||
|
||
export function toggleFolder(tag) {
|
||
const tagElement = (tag instanceof HTMLElement) ? tag : this;
|
||
const folder = tagElement.dataset.folder;
|
||
const wasActive = tagElement.classList.contains('active');
|
||
|
||
document.querySelectorAll('.folder-tags .tag').forEach(t => {
|
||
t.classList.remove('active');
|
||
});
|
||
|
||
if (!wasActive) {
|
||
tagElement.classList.add('active');
|
||
state.activeFolder = folder;
|
||
} else {
|
||
state.activeFolder = null;
|
||
}
|
||
|
||
resetAndReload();
|
||
}
|
||
|
||
function filterByFolder(folderPath) {
|
||
document.querySelectorAll('.lora-card').forEach(card => {
|
||
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
export function openCivitai(modelName) {
|
||
// 从卡片的data-meta属性中获取civitai ID
|
||
const loraCard = document.querySelector(`.lora-card[data-name="${modelName}"]`);
|
||
if (!loraCard) return;
|
||
|
||
const metaData = JSON.parse(loraCard.dataset.meta);
|
||
const civitaiId = metaData.modelId; // 使用modelId作为civitai模型ID
|
||
const versionId = metaData.id; // 使用id作为版本ID
|
||
|
||
// 构建URL
|
||
if (civitaiId) {
|
||
let url = `https://civitai.com/models/${civitaiId}`;
|
||
if (versionId) {
|
||
url += `?modelVersionId=${versionId}`;
|
||
}
|
||
window.open(url, '_blank');
|
||
} else {
|
||
// 如果没有ID,尝试使用名称搜索
|
||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dynamically positions the search options panel and filter panel
|
||
* based on the current layout and folder tags container height
|
||
*/
|
||
export function updatePanelPositions() {
|
||
const searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
||
const filterPanel = document.getElementById('filterPanel');
|
||
|
||
if (!searchOptionsPanel && !filterPanel) return;
|
||
|
||
// Get the header element
|
||
const header = document.querySelector('.app-header');
|
||
if (!header) return;
|
||
|
||
// Calculate the position based on the bottom of the header
|
||
const headerRect = header.getBoundingClientRect();
|
||
const topPosition = headerRect.bottom + 5; // Add 5px padding
|
||
|
||
// Set the positions
|
||
if (searchOptionsPanel) {
|
||
searchOptionsPanel.style.top = `${topPosition}px`;
|
||
}
|
||
|
||
if (filterPanel) {
|
||
filterPanel.style.top = `${topPosition}px`;
|
||
}
|
||
|
||
// Adjust panel horizontal position based on the search container
|
||
const searchContainer = document.querySelector('.header-search');
|
||
if (searchContainer) {
|
||
const searchRect = searchContainer.getBoundingClientRect();
|
||
|
||
// Position the search options panel aligned with the search container
|
||
if (searchOptionsPanel) {
|
||
searchOptionsPanel.style.right = `${window.innerWidth - searchRect.right}px`;
|
||
}
|
||
|
||
// Position the filter panel aligned with the filter button
|
||
if (filterPanel) {
|
||
const filterButton = document.getElementById('filterButton');
|
||
if (filterButton) {
|
||
const filterRect = filterButton.getBoundingClientRect();
|
||
filterPanel.style.right = `${window.innerWidth - filterRect.right}px`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update the toggleFolderTags function
|
||
export function toggleFolderTags() {
|
||
const folderTags = document.querySelector('.folder-tags');
|
||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||
|
||
if (folderTags) {
|
||
folderTags.classList.toggle('collapsed');
|
||
|
||
if (folderTags.classList.contains('collapsed')) {
|
||
// Change icon to indicate folders are hidden
|
||
toggleBtn.className = 'fas fa-folder-plus';
|
||
toggleBtn.parentElement.title = 'Show folder tags';
|
||
setStorageItem('folderTagsCollapsed', 'true');
|
||
} else {
|
||
// Change icon to indicate folders are visible
|
||
toggleBtn.className = 'fas fa-folder-minus';
|
||
toggleBtn.parentElement.title = 'Hide folder tags';
|
||
setStorageItem('folderTagsCollapsed', 'false');
|
||
}
|
||
|
||
// Update panel positions after toggling
|
||
// Use a small delay to ensure the DOM has updated
|
||
setTimeout(() => {
|
||
updatePanelPositions();
|
||
}, 50);
|
||
}
|
||
}
|
||
|
||
// Add this to your existing initialization code
|
||
export function initFolderTagsVisibility() {
|
||
const isCollapsed = getStorageItem('folderTagsCollapsed');
|
||
if (isCollapsed) {
|
||
const folderTags = document.querySelector('.folder-tags');
|
||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||
if (folderTags) {
|
||
folderTags.classList.add('collapsed');
|
||
}
|
||
if (toggleBtn) {
|
||
toggleBtn.className = 'fas fa-folder-plus';
|
||
toggleBtn.parentElement.title = 'Show folder tags';
|
||
}
|
||
} else {
|
||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||
if (toggleBtn) {
|
||
toggleBtn.className = 'fas fa-folder-minus';
|
||
toggleBtn.parentElement.title = 'Hide folder tags';
|
||
}
|
||
}
|
||
}
|
||
|
||
export function initBackToTop() {
|
||
const button = document.getElementById('backToTopBtn');
|
||
if (!button) return;
|
||
|
||
// Get the scrollable container
|
||
const scrollContainer = document.querySelector('.page-content');
|
||
|
||
// Show/hide button based on scroll position
|
||
const toggleBackToTop = () => {
|
||
const scrollThreshold = window.innerHeight * 0.3;
|
||
if (scrollContainer.scrollTop > scrollThreshold) {
|
||
button.classList.add('visible');
|
||
} else {
|
||
button.classList.remove('visible');
|
||
}
|
||
};
|
||
|
||
// Smooth scroll to top
|
||
button.addEventListener('click', () => {
|
||
scrollContainer.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth'
|
||
});
|
||
});
|
||
|
||
// Listen for scroll events on the scrollable container
|
||
scrollContainer.addEventListener('scroll', toggleBackToTop);
|
||
|
||
// Initial check
|
||
toggleBackToTop();
|
||
}
|
||
|
||
export function getNSFWLevelName(level) {
|
||
if (level === 0) return 'Unknown';
|
||
if (level >= 32) return 'Blocked';
|
||
if (level >= 16) return 'XXX';
|
||
if (level >= 8) return 'X';
|
||
if (level >= 4) return 'R';
|
||
if (level >= 2) return 'PG13';
|
||
if (level >= 1) return 'PG';
|
||
return 'Unknown';
|
||
}
|
||
|
||
/**
|
||
* Sends LoRA syntax to the active ComfyUI workflow
|
||
* @param {string} loraSyntax - The LoRA syntax to send
|
||
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
|
||
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
||
* @returns {Promise<boolean>} - Whether the operation was successful
|
||
*/
|
||
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
|
||
try {
|
||
let loraNodes = [];
|
||
let isDesktopMode = false;
|
||
|
||
// Get the current workflow from localStorage
|
||
const workflowData = localStorage.getItem('workflow');
|
||
if (workflowData) {
|
||
// Web browser mode - extract node IDs from workflow
|
||
const workflow = JSON.parse(workflowData);
|
||
|
||
// Find all Lora Loader (LoraManager) nodes
|
||
if (workflow.nodes && Array.isArray(workflow.nodes)) {
|
||
for (const node of workflow.nodes) {
|
||
if (node.type === "Lora Loader (LoraManager)") {
|
||
loraNodes.push(node.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (loraNodes.length === 0) {
|
||
showToast('No Lora Loader nodes found in the workflow', 'warning');
|
||
return false;
|
||
}
|
||
} else {
|
||
// ComfyUI Desktop mode - don't specify node IDs and let backend handle it
|
||
isDesktopMode = true;
|
||
}
|
||
|
||
// Call the backend API to update the lora code
|
||
const response = await fetch('/api/update-lora-code', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
node_ids: isDesktopMode ? undefined : loraNodes,
|
||
lora_code: loraSyntax,
|
||
mode: replaceMode ? 'replace' : 'append'
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// Use different toast messages based on syntax type
|
||
if (syntaxType === 'recipe') {
|
||
showToast(`Recipe ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
|
||
} else {
|
||
showToast(`LoRA ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
|
||
}
|
||
return true;
|
||
} else {
|
||
showToast(result.error || `Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to send to workflow:', error);
|
||
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Opens the example images folder for a specific model
|
||
* @param {string} modelHash - The SHA256 hash of the model
|
||
*/
|
||
export async function openExampleImagesFolder(modelHash) {
|
||
try {
|
||
const response = await fetch('/api/open-example-images-folder', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model_hash: modelHash
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Opening example images folder', 'success');
|
||
return true;
|
||
} else {
|
||
showToast(result.error || 'Failed to open example images folder', 'error');
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to open example images folder:', error);
|
||
showToast('Failed to open example images folder', 'error');
|
||
return false;
|
||
}
|
||
} |