mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-02 23:41:16 -03:00
feat(ui): add searchable base model dropdown with filename inference in model modal
Replace native <select> with a searchable dropdown that: - Filters options as the user types - Shows filename-inferred suggestions at the top in a "Suggested" section - Supports keyboard navigation (ArrowUp/Down/Enter/Escape) - Allows typing custom values not in the list - Removes dead .base-model-selector CSS Adds 3 new i18n keys (baseModelSearchPlaceholder, baseModelSuggested, baseModelNoMatch) with translations for all 9 locales.
This commit is contained in:
4289
locales/de.json
4289
locales/de.json
File diff suppressed because it is too large
Load Diff
@@ -1347,7 +1347,10 @@
|
|||||||
"additionalNotes": "Additional Notes",
|
"additionalNotes": "Additional Notes",
|
||||||
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
||||||
"addNotesPlaceholder": "Add your notes here...",
|
"addNotesPlaceholder": "Add your notes here...",
|
||||||
"aboutThisVersion": "About this version"
|
"aboutThisVersion": "About this version",
|
||||||
|
"baseModelSearchPlaceholder": "Search base model…",
|
||||||
|
"baseModelSuggested": "Suggested",
|
||||||
|
"baseModelNoMatch": "No matching base models"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes saved successfully",
|
"saved": "Notes saved successfully",
|
||||||
|
|||||||
4289
locales/es.json
4289
locales/es.json
File diff suppressed because it is too large
Load Diff
4289
locales/fr.json
4289
locales/fr.json
File diff suppressed because it is too large
Load Diff
4289
locales/he.json
4289
locales/he.json
File diff suppressed because it is too large
Load Diff
4289
locales/ja.json
4289
locales/ja.json
File diff suppressed because it is too large
Load Diff
4289
locales/ko.json
4289
locales/ko.json
File diff suppressed because it is too large
Load Diff
4289
locales/ru.json
4289
locales/ru.json
File diff suppressed because it is too large
Load Diff
4289
locales/zh-CN.json
4289
locales/zh-CN.json
File diff suppressed because it is too large
Load Diff
4289
locales/zh-TW.json
4289
locales/zh-TW.json
File diff suppressed because it is too large
Load Diff
@@ -444,16 +444,161 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-model-selector {
|
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
|
||||||
width: 100%;
|
|
||||||
padding: 3px 5px;
|
.base-model-search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 0 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input-wrapper .search-icon {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.45;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Reset global .search-icon rules from search-filter.css */
|
||||||
|
position: static;
|
||||||
|
right: auto;
|
||||||
|
top: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
outline: none;
|
padding: 3px 0;
|
||||||
margin-right: var(--space-1);
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input::placeholder {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
max-height: 270px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .base-model-dropdown {
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown scrollbar styling */
|
||||||
|
.base-model-dropdown::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--lora-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.base-model-dropdown-section {
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.base-model-dropdown-header {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-header.suggested-header {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-header.suggested-header i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown items */
|
||||||
|
.base-model-dropdown-item {
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item:hover {
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.active {
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.selected::after {
|
||||||
|
content: '✓';
|
||||||
|
float: right;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.base-model-dropdown-empty {
|
||||||
|
padding: 18px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.88em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-wrapper {
|
.size-wrapper {
|
||||||
|
|||||||
@@ -6,6 +6,72 @@
|
|||||||
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
// ── Filename-based base model inference ──────────────────────────────────────
|
||||||
|
// Rules are ordered by specificity — first match wins for dedup.
|
||||||
|
// Each rule checks the filename (lowercased) for a regex pattern and suggests
|
||||||
|
// the associated base model values.
|
||||||
|
|
||||||
|
const BASE_MODEL_FILENAME_RULES = [
|
||||||
|
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
|
||||||
|
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
|
||||||
|
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
|
||||||
|
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
|
||||||
|
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
|
||||||
|
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
|
||||||
|
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
|
||||||
|
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
|
||||||
|
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
|
||||||
|
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
|
||||||
|
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
|
||||||
|
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
|
||||||
|
{ pattern: /illustrious/i, models: ['Illustrious'] },
|
||||||
|
{ pattern: /noobai/i, models: ['NoobAI'] },
|
||||||
|
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
|
||||||
|
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
|
||||||
|
{ pattern: /kolors/i, models: ['Kolors'] },
|
||||||
|
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
|
||||||
|
{ pattern: /lumina/i, models: ['Lumina'] },
|
||||||
|
{ pattern: /hidream/i, models: ['HiDream'] },
|
||||||
|
{ pattern: /qwen/i, models: ['Qwen'] },
|
||||||
|
{ pattern: /chroma/i, models: ['Chroma'] },
|
||||||
|
{ pattern: /anima/i, models: ['Anima'] },
|
||||||
|
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
|
||||||
|
{ pattern: /mochi/i, models: ['Mochi'] },
|
||||||
|
{ pattern: /svd/i, models: ['SVD'] },
|
||||||
|
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
|
||||||
|
{ pattern: /nucleus/i, models: ['Nucleus'] },
|
||||||
|
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
|
||||||
|
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer likely base model(s) from a filename + model name string.
|
||||||
|
* Returns a deduplicated array in match-priority order.
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function inferBaseModelsFromFilename(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const results = [];
|
||||||
|
for (const rule of BASE_MODEL_FILENAME_RULES) {
|
||||||
|
if (rule.pattern.test(filename)) {
|
||||||
|
for (const model of rule.models) {
|
||||||
|
if (!seen.has(model)) {
|
||||||
|
seen.add(model);
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active file path for the currently open model modal.
|
* Resolve the active file path for the currently open model modal.
|
||||||
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up base model editing functionality
|
* Set up base model editing functionality with searchable dropdown
|
||||||
|
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
|
||||||
|
* and allows typing custom values.
|
||||||
* @param {string} filePath - File path
|
* @param {string} filePath - File path
|
||||||
*/
|
*/
|
||||||
export function setupBaseModelEditing(filePath) {
|
export function setupBaseModelEditing(filePath) {
|
||||||
@@ -257,116 +325,251 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
// Store the original value to check for changes later
|
// Store the original value to check for changes later
|
||||||
const originalValue = baseModelContent.textContent.trim();
|
const originalValue = baseModelContent.textContent.trim();
|
||||||
|
|
||||||
// Create dropdown selector to replace the base model content
|
// ── Build the full option list ────────────────────────────────────────
|
||||||
const currentValue = originalValue;
|
const allModels = []; // { value, label, category }
|
||||||
const dropdown = document.createElement('select');
|
|
||||||
dropdown.className = 'base-model-selector';
|
|
||||||
|
|
||||||
// Flag to track if a change was made
|
|
||||||
let valueChanged = false;
|
|
||||||
|
|
||||||
// Add options from BASE_MODEL_CATEGORIES constants
|
|
||||||
const baseModelCategories = BASE_MODEL_CATEGORIES;
|
|
||||||
const categorizedModels = new Set();
|
const categorizedModels = new Set();
|
||||||
|
|
||||||
// Create option groups for better organization
|
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
||||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
|
||||||
const group = document.createElement('optgroup');
|
|
||||||
group.label = category;
|
|
||||||
|
|
||||||
models.forEach(model => {
|
models.forEach(model => {
|
||||||
const option = document.createElement('option');
|
allModels.push({ value: model, label: model, category });
|
||||||
option.value = model;
|
|
||||||
option.textContent = model;
|
|
||||||
if (model === currentValue) option.selected = true;
|
|
||||||
categorizedModels.add(model);
|
categorizedModels.add(model);
|
||||||
group.appendChild(option);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dropdown.appendChild(group);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for dynamic base models from API that aren't in any category
|
|
||||||
const mergedModels = getMergedBaseModels();
|
const mergedModels = getMergedBaseModels();
|
||||||
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
||||||
if (uncategorizedModels.length > 0) {
|
if (uncategorizedModels.length > 0) {
|
||||||
const group = document.createElement('optgroup');
|
|
||||||
group.label = 'Other (API)';
|
|
||||||
uncategorizedModels.forEach(model => {
|
uncategorizedModels.forEach(model => {
|
||||||
const option = document.createElement('option');
|
allModels.push({ value: model, label: model, category: 'Other (API)' });
|
||||||
option.value = model;
|
|
||||||
option.textContent = model;
|
|
||||||
if (model === currentValue) option.selected = true;
|
|
||||||
group.appendChild(option);
|
|
||||||
});
|
});
|
||||||
dropdown.appendChild(group);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace content with dropdown
|
// ── Filename-based inference ──────────────────────────────────────────
|
||||||
baseModelContent.style.display = 'none';
|
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
||||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
(document.querySelector('.model-name-content')?.textContent || '');
|
||||||
|
const inferredModels = inferBaseModelsFromFilename(fileName);
|
||||||
|
const inferredSet = new Set(inferredModels);
|
||||||
|
|
||||||
// Hide edit button during editing
|
// ── Build search widget DOM ───────────────────────────────────────────
|
||||||
editBtn.style.display = 'none';
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'base-model-search-wrapper';
|
||||||
|
|
||||||
// Focus the dropdown
|
// Search input row
|
||||||
dropdown.focus();
|
const inputWrapper = document.createElement('div');
|
||||||
|
inputWrapper.className = 'base-model-search-input-wrapper';
|
||||||
|
const searchIcon = document.createElement('i');
|
||||||
|
searchIcon.className = 'fas fa-search search-icon';
|
||||||
|
searchIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
inputWrapper.appendChild(searchIcon);
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.className = 'base-model-search-input';
|
||||||
|
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
|
||||||
|
searchInput.autocomplete = 'off';
|
||||||
|
searchInput.spellcheck = false;
|
||||||
|
inputWrapper.appendChild(searchInput);
|
||||||
|
wrapper.appendChild(inputWrapper);
|
||||||
|
|
||||||
// Handle dropdown change
|
// Dropdown list
|
||||||
dropdown.addEventListener('change', function() {
|
const dropdown = document.createElement('div');
|
||||||
const selectedModel = this.value;
|
dropdown.className = 'base-model-dropdown';
|
||||||
baseModelContent.textContent = selectedModel;
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
function renderDropdown(filterText) {
|
||||||
|
const lowerFilter = (filterText || '').toLowerCase().trim();
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
let hasVisibleItems = false;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// Mark that a change was made if the value differs from original
|
// 1. Suggested section (filename-inferred, filtered by search)
|
||||||
if (selectedModel !== originalValue) {
|
let suggestedToShow = inferredModels;
|
||||||
valueChanged = true;
|
if (lowerFilter) {
|
||||||
} else {
|
suggestedToShow = inferredModels.filter(m =>
|
||||||
valueChanged = false;
|
m.toLowerCase().includes(lowerFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedToShow.length > 0) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header suggested-header';
|
||||||
|
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
|
||||||
|
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
suggestedToShow.forEach(model => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (model === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = model;
|
||||||
|
item.textContent = model;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Categorized options (deduplicated against suggestions)
|
||||||
|
const categoryMap = {};
|
||||||
|
allModels.forEach(m => {
|
||||||
|
if (inferredSet.has(m.value)) return; // already shown in Suggested
|
||||||
|
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
|
||||||
|
if (!categoryMap[m.category]) categoryMap[m.category] = [];
|
||||||
|
categoryMap[m.category].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(categoryMap).forEach(([category, items]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header';
|
||||||
|
header.textContent = category;
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
items.forEach(m => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (m.value === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = m.value;
|
||||||
|
item.textContent = m.label;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Empty state
|
||||||
|
if (!hasVisibleItems) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'base-model-dropdown-empty';
|
||||||
|
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
|
||||||
|
fragment.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.appendChild(fragment);
|
||||||
|
|
||||||
|
// Scroll the selected item into view
|
||||||
|
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
|
||||||
|
if (selected) {
|
||||||
|
selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — show everything
|
||||||
|
renderDropdown('');
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────
|
||||||
|
let filterTimeout;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(filterTimeout);
|
||||||
|
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.base-model-dropdown-item');
|
||||||
|
if (!item) return;
|
||||||
|
baseModelContent.textContent = item.dataset.value;
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to save changes and exit edit mode
|
// Replace content with search widget
|
||||||
const saveAndExit = function() {
|
baseModelContent.style.display = 'none';
|
||||||
// Check if dropdown still exists and remove it
|
editBtn.style.display = 'none';
|
||||||
if (dropdown && dropdown.parentNode === baseModelDisplay) {
|
baseModelDisplay.insertBefore(wrapper, editBtn);
|
||||||
baseModelDisplay.removeChild(dropdown);
|
searchInput.focus();
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
|
function cleanup() {
|
||||||
|
if (wrapper.parentNode === baseModelDisplay) {
|
||||||
|
baseModelDisplay.removeChild(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the content and edit button
|
|
||||||
baseModelContent.style.display = '';
|
baseModelContent.style.display = '';
|
||||||
editBtn.style.display = '';
|
editBtn.style.display = '';
|
||||||
|
|
||||||
// Remove editing class
|
|
||||||
baseModelDisplay.classList.remove('editing');
|
baseModelDisplay.classList.remove('editing');
|
||||||
|
|
||||||
// Only save if the value has actually changed
|
|
||||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
|
||||||
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
|
|
||||||
saveBaseModel(resolvedPath, originalValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove this event listener
|
|
||||||
document.removeEventListener('click', outsideClickHandler);
|
document.removeEventListener('click', outsideClickHandler);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle outside clicks to save and exit
|
// Outside click → save typed/custom value if any
|
||||||
const outsideClickHandler = function(e) {
|
const outsideClickHandler = function(e) {
|
||||||
// If click is outside the dropdown and base model display
|
if (wrapper.contains(e.target)) return;
|
||||||
if (!baseModelDisplay.contains(e.target)) {
|
|
||||||
saveAndExit();
|
// If user typed a custom value (not just empty), apply it
|
||||||
|
const typedValue = searchInput.value.trim();
|
||||||
|
if (typedValue) {
|
||||||
|
baseModelContent.textContent = typedValue;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add delayed event listener for outside clicks
|
// Defer listener to avoid the opening click itself
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', outsideClickHandler);
|
document.addEventListener('click', outsideClickHandler);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Also handle dropdown blur event
|
// Keyboard navigation
|
||||||
dropdown.addEventListener('blur', function(e) {
|
searchInput.addEventListener('keydown', function onKeydown(e) {
|
||||||
// Only save if the related target is not the edit button or inside the baseModelDisplay
|
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
|
||||||
if (!baseModelDisplay.contains(e.relatedTarget)) {
|
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
||||||
saveAndExit();
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const next = Math.min(activeIdx + 1, items.length - 1);
|
||||||
|
if (items[next]) {
|
||||||
|
items[next].classList.add('active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const prev = Math.max(activeIdx - 1, 0);
|
||||||
|
if (items[prev]) {
|
||||||
|
items[prev].classList.add('active');
|
||||||
|
items[prev].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activeItem = items.find(el => el.classList.contains('active'));
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.click();
|
||||||
|
} else if (searchInput.value.trim()) {
|
||||||
|
// Custom value typed
|
||||||
|
baseModelContent.textContent = searchInput.value.trim();
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
baseModelContent.textContent = originalValue;
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user