From c2af282a85236eb431a06c60f01038d9cafab1f0 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 12 Jun 2025 21:00:17 +0800 Subject: [PATCH] Add tag editing functionality: implement UI for editing model tags, including save and delete options, and integrate with existing modal structure. --- static/css/components/lora-modal.css | 281 +++++++++++ .../js/components/loraModal/ModelMetadata.js | 34 -- static/js/components/loraModal/ModelTags.js | 459 ++++++++++++++++++ static/js/components/loraModal/index.js | 6 +- static/js/components/loraModal/utils.js | 18 +- 5 files changed, 756 insertions(+), 42 deletions(-) create mode 100644 static/js/components/loraModal/ModelTags.js diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 2263bd75..20e2fafe 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -1540,4 +1540,285 @@ height: 1px; background: var(--lora-border); margin: 5px 10px; +} + +/* Model Tags Edit Mode */ +.model-tags-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.edit-tags-btn { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0; + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; + margin-left: var(--space-1); +} + +.edit-tags-btn.visible, +.model-tags-container:hover .edit-tags-btn { + opacity: 0.5; +} + +.edit-tags-btn:hover { + opacity: 0.8 !important; + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .edit-tags-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Edit mode active state */ +.model-tags-container.edit-mode { + width: 100%; + display: block; /* Change to block display in edit mode */ + flex-basis: 100%; /* Take full width in flex contexts */ + grid-column: 1 / -1; /* Ensure it spans full width in grid layouts */ +} + +/* Fix for tags edit container width and position */ +.tags-edit-container { + padding: var(--space-2); + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + margin-top: var(--space-2); + width: 100%; /* Ensure it takes full width */ + min-width: 100%; /* Force minimum width */ + max-width: 100%; /* Limit maximum width */ + box-sizing: border-box; /* Include padding in width calculation */ + position: relative; /* For proper positioning of elements inside */ + display: block; /* Force block display */ +} + +[data-theme="dark"] .tags-edit-container { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.tags-edit-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--lora-border); + width: 100%; /* Ensure header takes full width */ +} + +/* Style for the edit button when positioned in the header */ +.edit-header-btn { + display: inline-flex !important; /* Always show */ + opacity: 0.8 !important; + color: var(--lora-accent) !important; + margin-left: auto; /* Push to right */ +} + +.tags-edit-content { + margin-bottom: var(--space-1); + width: 100%; /* Ensure full width */ + display: block; /* Force block display */ +} + +.tags-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; + margin-bottom: var(--space-2); + width: 100%; /* Ensure full width */ +} + +.tag-edit-tag { + display: inline-flex; + align-items: center; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: 4px 8px; + position: relative; +} + +.tag-edit-content { + color: var(--lora-accent) !important; + font-size: 0.85em; + line-height: 1.4; + word-break: break-word; +} + +/* Delete button for tag */ +.delete-tag-btn { + position: absolute; + top: -5px; + right: -5px; + width: 16px; + height: 16px; + background: var(--lora-error); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + transition: transform 0.2s ease; +} + +.delete-tag-btn:hover { + transform: scale(1.1); +} + +/* Edit controls */ +.tags-edit-controls { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-2); + margin-bottom: var(--space-2); +} + +.tags-edit-controls button { + padding: 3px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85em; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; +} + +.tags-edit-controls button:hover { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); + border-color: var(--lora-accent); +} + +.save-tags-btn { + background: var(--lora-accent) !important; + color: white !important; + border-color: var(--lora-accent) !important; +} + +.save-tags-btn:hover { + opacity: 0.9; +} + +/* Add tag form */ +.add-tag-form { + display: flex; + gap: var(--space-1); + position: relative; + width: 100%; /* Ensure full width */ +} + +.new-tag-input { + flex: 1; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.new-tag-input:focus { + border-color: var(--lora-accent); + outline: none; +} + +/* Tag Suggestions Dropdown Styles */ +.tag-suggestions-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + margin-top: 4px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tag-suggestions-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); +} + +.tag-suggestions-header span { + font-size: 0.9em; + font-weight: 500; + color: var(--text-color); +} + +.tag-suggestions-header small { + font-size: 0.8em; + opacity: 0.7; +} + +.tag-suggestions-container { + max-height: 200px; + overflow-y: auto; + padding: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-content: flex-start; +} + +.tag-suggestion-item { + display: inline-flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + cursor: pointer; + transition: all 0.2s ease; + border-radius: var(--border-radius-xs); + background: var(--lora-surface); + border: 1px solid var(--lora-border); + max-width: 150px; +} + +.tag-suggestion-item:hover { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); + border-color: var(--lora-accent); +} + +.tag-suggestion-item.already-added { + opacity: 0.7; + cursor: default; +} + +.tag-suggestion-item.already-added:hover { + background: var(--lora-surface); + border-color: var(--lora-border); +} + +.tag-suggestion-text { + color: var(--lora-accent); + font-size: 0.9em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 4px; + max-width: 100px; } \ No newline at end of file diff --git a/static/js/components/loraModal/ModelMetadata.js b/static/js/components/loraModal/ModelMetadata.js index 7b376cd1..d49d9cca 100644 --- a/static/js/components/loraModal/ModelMetadata.js +++ b/static/js/components/loraModal/ModelMetadata.js @@ -142,40 +142,6 @@ export function setupModelNameEditing(filePath) { } } -/** - * 保存模型名称 - * @param {string} filePath - 文件路径 - */ -async function saveModelName(filePath) { - const modelNameElement = document.querySelector('.model-name-content'); - const newModelName = modelNameElement.textContent.trim(); - - // Validate model name - if (!newModelName) { - showToast('Model name cannot be empty', 'error'); - return; - } - - // Check if model name is too long (limit to 100 characters) - if (newModelName.length > 100) { - showToast('Model name is too long (maximum 100 characters)', 'error'); - // Truncate the displayed text - modelNameElement.textContent = newModelName.substring(0, 100); - return; - } - - try { - await saveModelMetadata(filePath, { model_name: newModelName }); - - // Update the corresponding lora card's dataset and display - updateLoraCard(filePath, { model_name: newModelName }); - - showToast('Model name updated successfully', 'success'); - } catch (error) { - showToast('Failed to update model name', 'error'); - } -} - /** * 设置基础模型编辑功能 * @param {string} filePath - 文件路径 diff --git a/static/js/components/loraModal/ModelTags.js b/static/js/components/loraModal/ModelTags.js new file mode 100644 index 00000000..b1769766 --- /dev/null +++ b/static/js/components/loraModal/ModelTags.js @@ -0,0 +1,459 @@ +/** + * ModelTags.js + * Module for handling model tag editing functionality + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { saveModelMetadata } from '../../api/loraApi.js'; +import { updateLoraCard } from '../../utils/cardUpdater.js'; + +// Preset tag suggestions +const PRESET_TAGS = [ + 'character', 'style', 'concept', 'clothing', + 'poses', 'background', 'vehicle', 'buildings', + 'objects', 'animal' +]; + +/** + * Set up tag editing mode + */ +export function setupTagEditMode() { + const editBtn = document.querySelector('.edit-tags-btn'); + if (!editBtn) return; + + // Store original tags for restoring on cancel + let originalTags = []; + + editBtn.addEventListener('click', function() { + const tagsSection = document.querySelector('.model-tags-container'); + const isEditMode = tagsSection.classList.toggle('edit-mode'); + const filePath = this.dataset.filePath; + + // Toggle edit mode UI elements + const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact'); + const tagsEditContainer = tagsSection.querySelector('.tags-edit-container'); + + if (isEditMode) { + // Enter edit mode + this.innerHTML = ''; // Change to cancel icon + this.title = "Cancel editing"; + + // Get all tags from tooltip, not just the visible ones in compact display + originalTags = Array.from( + tagsSection.querySelectorAll('.tooltip-tag') + ).map(tag => tag.textContent); + + // Hide compact display, show edit container + compactTagsDisplay.style.display = 'none'; + + // If edit container doesn't exist yet, create it + if (!tagsEditContainer) { + const editContainer = document.createElement('div'); + editContainer.className = 'tags-edit-container'; + + // Move the edit button inside the container header for better visibility + const editBtnClone = editBtn.cloneNode(true); + editBtnClone.classList.add('edit-header-btn'); + + // Create edit UI with edit button in the header + editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML); + tagsSection.appendChild(editContainer); + + // Setup the tag input field behavior + setupTagInput(); + + // Create and add preset suggestions dropdown + const tagForm = editContainer.querySelector('.add-tag-form'); + const suggestionsDropdown = createSuggestionsDropdown(originalTags); + tagForm.appendChild(suggestionsDropdown); + + // Setup delete buttons for existing tags + setupDeleteButtons(); + + // Transfer click event from original button to the cloned one + const newEditBtn = editContainer.querySelector('.edit-header-btn'); + if (newEditBtn) { + newEditBtn.addEventListener('click', function() { + editBtn.click(); + }); + } + + // Hide the original button when in edit mode + editBtn.style.display = 'none'; + } else { + // Just show the existing edit container + tagsEditContainer.style.display = 'block'; + editBtn.style.display = 'none'; + } + } else { + // Exit edit mode + this.innerHTML = ''; // Change back to edit icon + this.title = "Edit tags"; + editBtn.style.display = 'block'; + + // Show compact display, hide edit container + compactTagsDisplay.style.display = 'flex'; + if (tagsEditContainer) tagsEditContainer.style.display = 'none'; + + // Check if we're exiting edit mode due to "Save" or "Cancel" + if (!this.dataset.skipRestore) { + // If canceling, restore original tags + restoreOriginalTags(tagsSection, originalTags); + } else { + // Reset the skip restore flag + delete this.dataset.skipRestore; + } + } + }); + + // Set up save button + document.addEventListener('click', function(e) { + if (e.target.classList.contains('save-tags-btn') || + e.target.closest('.save-tags-btn')) { + saveTags(); + } + }); +} + +/** + * Create the tag editing UI + * @param {Array} currentTags - Current tags + * @param {string} editBtnHTML - HTML for the edit button to include in header + * @returns {string} HTML markup for tag editing UI + */ +function createTagEditUI(currentTags, editBtnHTML = '') { + return ` +
+
+ + ${editBtnHTML} +
+
+ ${currentTags.map(tag => ` +
+ ${tag} + +
+ `).join('')} +
+
+ +
+
+ +
+
+ `; +} + +/** + * Create suggestions dropdown with preset tags + * @param {Array} existingTags - Already added tags + * @returns {HTMLElement} - Dropdown element + */ +function createSuggestionsDropdown(existingTags = []) { + const dropdown = document.createElement('div'); + dropdown.className = 'tag-suggestions-dropdown'; + + // Create header + const header = document.createElement('div'); + header.className = 'tag-suggestions-header'; + header.innerHTML = ` + Suggested Tags + Click to add + `; + dropdown.appendChild(header); + + // Create tag container + const container = document.createElement('div'); + container.className = 'tag-suggestions-container'; + + // Add each preset tag as a suggestion + PRESET_TAGS.forEach(tag => { + const isAdded = existingTags.includes(tag); + + const item = document.createElement('div'); + item.className = `tag-suggestion-item ${isAdded ? 'already-added' : ''}`; + item.title = tag; + item.innerHTML = ` + ${tag} + ${isAdded ? '' : ''} + `; + + if (!isAdded) { + item.addEventListener('click', () => { + addNewTag(tag); + + // Also populate the input field for potential editing + const input = document.querySelector('.new-tag-input'); + if (input) input.value = tag; + + // Focus on the input + if (input) input.focus(); + + // Update dropdown without removing it + updateSuggestionsDropdown(); + }); + } + + container.appendChild(item); + }); + + dropdown.appendChild(container); + return dropdown; +} + +/** + * Set up tag input behavior + */ +function setupTagInput() { + const tagInput = document.querySelector('.new-tag-input'); + + if (tagInput) { + tagInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addNewTag(this.value); + this.value = ''; // Clear input after adding + } + }); + } +} + +/** + * Set up delete buttons for tags + */ +function setupDeleteButtons() { + document.querySelectorAll('.delete-tag-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const tag = this.closest('.tag-edit-tag'); + tag.remove(); + + // Update status of items in the suggestion dropdown + updateSuggestionsDropdown(); + }); + }); +} + +/** + * Add a new tag + * @param {string} tag - Tag to add + */ +function addNewTag(tag) { + tag = tag.trim().toLowerCase(); + if (!tag) return; + + const tagsContainer = document.querySelector('.tags-tags'); + if (!tagsContainer) return; + + // Validation: Check length + if (tag.length > 30) { + showToast('Tag should not exceed 30 characters', 'error'); + return; + } + + // Validation: Check total number + const currentTags = tagsContainer.querySelectorAll('.tag-edit-tag'); + if (currentTags.length >= 30) { + showToast('Maximum 30 tags allowed', 'error'); + return; + } + + // Validation: Check for duplicates + const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag); + if (existingTags.includes(tag)) { + showToast('This tag already exists', 'error'); + return; + } + + // Create new tag + const newTag = document.createElement('div'); + newTag.className = 'tag-edit-tag'; + newTag.dataset.tag = tag; + newTag.innerHTML = ` + ${tag} + + `; + + // Add event listener to delete button + const deleteBtn = newTag.querySelector('.delete-tag-btn'); + deleteBtn.addEventListener('click', function(e) { + e.stopPropagation(); + newTag.remove(); + + // Update status of items in the suggestion dropdown + updateSuggestionsDropdown(); + }); + + tagsContainer.appendChild(newTag); + + // Update status of items in the suggestions dropdown + updateSuggestionsDropdown(); +} + +/** + * Update status of items in the suggestions dropdown + */ +function updateSuggestionsDropdown() { + const dropdown = document.querySelector('.tag-suggestions-dropdown'); + if (!dropdown) return; + + // Get all current tags + const currentTags = document.querySelectorAll('.tag-edit-tag'); + const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag); + + // Update status of each item in dropdown + dropdown.querySelectorAll('.tag-suggestion-item').forEach(item => { + const tagText = item.querySelector('.tag-suggestion-text').textContent; + const isAdded = existingTags.includes(tagText); + + if (isAdded) { + item.classList.add('already-added'); + + // Add indicator if it doesn't exist + let indicator = item.querySelector('.added-indicator'); + if (!indicator) { + indicator = document.createElement('span'); + indicator.className = 'added-indicator'; + indicator.innerHTML = ''; + item.appendChild(indicator); + } + + // Remove click event + item.onclick = null; + } else { + // Re-enable items that are no longer in the list + item.classList.remove('already-added'); + + // Remove indicator if it exists + const indicator = item.querySelector('.added-indicator'); + if (indicator) indicator.remove(); + + // Restore click event if not already set + if (!item.onclick) { + item.onclick = () => { + const tag = item.querySelector('.tag-suggestion-text').textContent; + addNewTag(tag); + + // Also populate the input field + const input = document.querySelector('.new-tag-input'); + if (input) input.value = tag; + + // Focus the input + if (input) input.focus(); + }; + } + } + }); +} + +/** + * Restore original tags when canceling edit + * @param {HTMLElement} section - The tags section + * @param {Array} originalTags - Original tags array + */ +function restoreOriginalTags(section, originalTags) { + // Nothing to do here as we're just hiding the edit UI + // and showing the original compact tags which weren't modified +} + +/** + * Save tags + */ +async function saveTags() { + const editBtn = document.querySelector('.edit-tags-btn'); + if (!editBtn) return; + + const filePath = editBtn.dataset.filePath; + const tagElements = document.querySelectorAll('.tag-edit-tag'); + const tags = Array.from(tagElements).map(tag => tag.dataset.tag); + + // Get original tags to compare + const originalTagElements = document.querySelectorAll('.tooltip-tag'); + const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); + + // Check if tags have actually changed + const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags); + + if (!tagsChanged) { + // No changes made, just exit edit mode without API call + editBtn.dataset.skipRestore = "true"; + editBtn.click(); + return; + } + + try { + // Save tags metadata + await saveModelMetadata(filePath, { tags: tags }); + + // Set flag to skip restoring original tags when exiting edit mode + editBtn.dataset.skipRestore = "true"; + + // Update the compact tags display + const compactTagsContainer = document.querySelector('.model-tags-container'); + if (compactTagsContainer) { + // Generate new compact tags HTML + const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); + + if (compactTagsDisplay) { + // Clear current tags + compactTagsDisplay.innerHTML = ''; + + // Add visible tags (up to 5) + const visibleTags = tags.slice(0, 5); + visibleTags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'model-tag-compact'; + span.textContent = tag; + compactTagsDisplay.appendChild(span); + }); + + // Add more indicator if needed + const remainingCount = Math.max(0, tags.length - 5); + if (remainingCount > 0) { + const more = document.createElement('span'); + more.className = 'model-tag-more'; + more.dataset.count = remainingCount; + more.textContent = `+${remainingCount}`; + compactTagsDisplay.appendChild(more); + } + } + + // Update tooltip content + const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); + if (tooltipContent) { + tooltipContent.innerHTML = ''; + + tags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'tooltip-tag'; + span.textContent = tag; + tooltipContent.appendChild(span); + }); + } + } + + // Exit edit mode + editBtn.click(); + + // Update the LoRA card's dataset + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard) { + loraCard.dataset.tags = JSON.stringify(tags); + + // Also update the card in the DOM + // updateLoraCard(loraCard, { tags: tags }); + } + + showToast('Tags updated successfully', 'success'); + } catch (error) { + console.error('Error saving tags:', error); + showToast('Failed to update tags', 'error'); + } +} diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js index 730a2787..a2f4db1d 100644 --- a/static/js/components/loraModal/index.js +++ b/static/js/components/loraModal/index.js @@ -9,7 +9,8 @@ import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; -import { loadRecipesForLora } from './RecipeTab.js'; // Add import for recipe tab +import { loadRecipesForLora } from './RecipeTab.js'; +import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing import { setupModelNameEditing, setupBaseModelEditing, @@ -52,7 +53,7 @@ export function showLoraModal(lora) { ${lora.civitai.creator.username} ` : ''} - ${renderCompactTags(lora.tags || [])} + ${renderCompactTags(lora.tags || [], lora.file_path)}