feat(tags): unify recipe modal tag UI with model modal

- Replace recipe modal's custom tag display/edit with shared
  renderCompactTags/setupTagEditMode from ModelTags and utils
- Remove 300+ lines of duplicated tag display and editing code
- Parameterize setupTagEditMode with saveHandler/onSaved/showSuggestions
  options for recipe-specific save flow (updateRecipeMetadata + dirty state)
- Scope all DOM queries in ModelTags.js via options.container / this.closest
  to prevent cross-modal element conflicts
- Fix edit button alignment (justify-content: flex-start)
- Fix tag tooltip selector scoping in setupTagTooltip
- Add width: 100% to #recipeTagsContainer for edit container full width
This commit is contained in:
Will Miao
2026-06-19 16:31:27 +08:00
parent cf0fd0e0ad
commit 968d6d1d1f
7 changed files with 194 additions and 537 deletions

View File

@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
@@ -139,14 +141,6 @@ class RecipeModal {
this.saveTitleEdit();
}
// Handle tags edit
const tagsEditor = document.getElementById('recipeTagsEditor');
if (tagsEditor && tagsEditor.classList.contains('active') &&
!tagsEditor.contains(event.target) &&
!event.target.closest('.edit-icon')) {
this.saveTagsEdit();
}
// Handle reconnect input
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
reconnectContainers.forEach(container => {
@@ -236,98 +230,10 @@ class RecipeModal {
this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tagsCompactElement) {
// Add tags container with edit functionality
tagsCompactElement.innerHTML = `
<div class="editable-content tags-content">
<div class="tags-display"></div>
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
</div>
<div id="recipeTagsEditor" class="content-editor tags-editor">
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
</div>
`;
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
});
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tagsTooltipContent.appendChild(tooltipTag);
});
}
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Add event listeners for tags editing
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
// Add key event listener for Enter key
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveTagsEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelTagsEdit();
}
});
// Render tags using shared utility
const tagsContainer = document.getElementById('recipeTagsContainer');
if (tagsContainer) {
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
}
// Set recipe image
@@ -609,17 +515,35 @@ class RecipeModal {
}
syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) {
return;
}
const container = document.getElementById('recipeTagsContainer');
if (!container) return;
this.updateTagsDisplay(container, tags || []);
}
this.updateTagsDisplay(tagsContainer, tags || []);
// Re-render tags display using shared utility, wire edit mode with ModelTags
updateTagsDisplay(container, tags) {
const filePath = this.filePath || '';
const tagsInput = tagsContainer.querySelector('.tags-input');
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
}
container.innerHTML = renderCompactTags(tags, filePath);
// Setup tooltip for all tags
setupTagTooltip(container);
// Wire edit button using shared tag editing (no suggestions for recipes)
setupTagEditMode(null, {
container: container,
showSuggestions: false,
normalizeTag: false,
saveHandler: async (filePath, tags) => {
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
},
onSaved: (tags) => {
this.currentRecipe.tags = tags;
this.commitField('tags');
const c = document.getElementById('recipeTagsContainer');
if (c) this.updateTagsDisplay(c, tags);
},
});
}
syncPromptField(field, value, placeholder) {
@@ -976,139 +900,6 @@ class RecipeModal {
}
}
// Tags editing methods
showTagsEditor() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
tagsContainer.querySelector('.editable-content').classList.add('hide');
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
saveTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
const tagsText = input.value.trim();
// Parse tags
let newTags = [];
if (tagsText) {
newTags = tagsText.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
// Check if tags changed
const oldTags = this.currentRecipe.tags || [];
const tagsChanged =
newTags.length !== oldTags.length ||
newTags.some((tag, index) => tag !== oldTags[index]);
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
// Reset input value
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
setupPromptEditors() {
const promptConfigs = [
{

View File

@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
showSuggestions: true,
saveHandler: null,
onSaved: null,
normalizeTag: true,
};
function normalizeModelTypeKey(modelType) {
if (!modelType) {
return '';
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
/**
* Set up tag editing mode
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
*/
export function setupTagEditMode(modelType = null) {
const editBtn = document.querySelector('.edit-tags-btn');
export function setupTagEditMode(modelType = null, options = {}) {
// Store options for use by saveTags and addNewTag
tagEditOptions = {
showSuggestions: options.showSuggestions !== false,
saveHandler: options.saveHandler || null,
onSaved: options.onSaved || null,
normalizeTag: options.normalizeTag !== false,
};
const root = options.container || document;
const editBtn = root.querySelector('.edit-tags-btn');
if (!editBtn) return;
setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions();
if (tagEditOptions.showSuggestions) {
setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions();
}
// Store original tags for restoring on cancel
let originalTags = [];
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const tagsSection = this.closest('.model-tags-container');
if (!tagsSection) return;
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
setupTagInput(tagsSection);
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
if (tagEditOptions.showSuggestions) {
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
}
// Setup delete buttons for existing tags
setupDeleteButtons();
setupTagDragAndDrop();
setupTagDragAndDrop(tagsSection);
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
setupTagDragAndDrop();
setupTagDragAndDrop(tagsSection);
}
} else {
// Exit edit mode
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
saveTags(e.target);
}
};
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
/**
* Save tags
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
async function saveTags(triggerElement = null) {
let editBtn;
let scope;
if (triggerElement) {
scope = triggerElement.closest('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
} else {
scope = document.querySelector('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
}
if (!editBtn || !scope) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tagElements = scope.querySelectorAll('.metadata-item');
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.metadata-input');
const tagInput = scope.querySelector('.metadata-input');
if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase();
const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag);
}
@@ -287,7 +324,7 @@ async function saveTags() {
}
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTagElements = scope.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
@@ -301,59 +338,68 @@ async function saveTags() {
}
try {
// Save tags metadata
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
// Use custom save handler if provided, otherwise default model API
if (tagEditOptions.saveHandler) {
await tagEditOptions.saveHandler(filePath, tags);
} else {
await getModelApiClient().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 = '';
// Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
if (tagEditOptions.onSaved) {
tagEditOptions.onSaved(tags);
} else {
// Update the compact tags display
const compactTagsContainer = scope;
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
// 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);
});
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);
}
}
// 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);
});
}
}
// 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();
}
// Exit edit mode
editBtn.click();
showToast('modelTags.messages.updated', {}, 'success');
} catch (error) {
console.error('Error saving tags:', error);
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
/**
* Set up tag input behavior
* @param {Element} scopeContainer - The .model-tags-container element
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
function setupTagInput(scopeContainer) {
const tagInput = scopeContainer
? scopeContainer.querySelector('.metadata-input')
: document.querySelector('.metadata-input');
if (tagInput) {
tagInput.focus();
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
addNewTag(this.value, this);
this.value = ''; // Clear input after adding
}
});
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
/**
* Enable drag-and-drop sorting for tag items
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
*/
function setupTagDragAndDrop() {
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
function setupTagDragAndDrop(scopeContainer) {
const container = scopeContainer
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!container) {
return;
}
@@ -712,12 +764,14 @@ function finishPointerDrag() {
/**
* Add a new tag
* @param {string} tag - Tag to add
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
function addNewTag(tag, scopeElement = null) {
tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
const tagsContainer = scope.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
@@ -762,7 +816,7 @@ function addNewTag(tag) {
});
tagsContainer.appendChild(newTag);
setupTagDragAndDrop();
setupTagDragAndDrop(scope);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();

View File

@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
/**
* Set up tag tooltip functionality
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
*/
export function setupTagTooltip() {
const tagsContainer = document.querySelector('.model-tags-container');
const tooltip = document.querySelector('.model-tags-tooltip');
export function setupTagTooltip(scopeContainer = null) {
const root = scopeContainer || document;
const tagsContainer = root.querySelector('.model-tags-container');
const tooltip = root.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => {