Add tag editing functionality: implement UI for editing model tags, including save and delete options, and integrate with existing modal structure.

This commit is contained in:
Will Miao
2025-06-12 21:00:17 +08:00
parent 92d48335cb
commit c2af282a85
5 changed files with 756 additions and 42 deletions

View File

@@ -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;
}

View File

@@ -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 - 文件路径

View File

@@ -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 = '<i class="fas fa-times"></i>'; // 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 = '<i class="fas fa-pencil-alt"></i>'; // 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 `
<div class="tags-edit-content">
<div class="tags-edit-header">
<label>Edit Tags</label>
${editBtnHTML}
</div>
<div class="tags-tags">
${currentTags.map(tag => `
<div class="tag-edit-tag" data-tag="${tag}">
<span class="tag-edit-content">${tag}</span>
<button class="delete-tag-btn">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
<div class="tags-edit-controls">
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="add-tag-form">
<input type="text" class="new-tag-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* 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 = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
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 = `
<span class="tag-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
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 = `
<span class="tag-edit-content">${tag}</span>
<button class="delete-tag-btn">
<i class="fas fa-times"></i>
</button>
`;
// 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 = '<i class="fas fa-check"></i>';
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');
}
}

View File

@@ -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) {
<span class="creator-username">${lora.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(lora.tags || [])}
${renderCompactTags(lora.tags || [], lora.file_path)}
</header>
<div class="modal-body">
@@ -177,6 +178,7 @@ export function showLoraModal(lora) {
setupModelNameEditing(lora.file_path);
setupBaseModelEditing(lora.file_path);
setupFileNameEditing(lora.file_path);
setupTagEditMode(); // Initialize tag editing functionality
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {

View File

@@ -26,9 +26,10 @@ export function formatFileSize(bytes) {
/**
* 渲染紧凑标签
* @param {Array} tags - 标签数组
* @param {string} filePath - 文件路径,用于编辑按钮
* @returns {string} HTML内容
*/
export function renderCompactTags(tags) {
export function renderCompactTags(tags, filePath = '') {
if (!tags || tags.length === 0) return '';
// Display up to 5 tags, with a tooltip indicator if there are more
@@ -37,11 +38,16 @@ export function renderCompactTags(tags) {
return `
<div class="model-tags-container">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
</div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tags.length > 0 ?
`<div class="model-tags-tooltip">