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:
Will Miao
2026-07-01 14:31:08 +08:00
parent 8b344ea39f
commit fe90f7f9b1
12 changed files with 19747 additions and 19369 deletions

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "Zusätzliche Notizen", "additionalNotes": "Zusätzliche Notizen",
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile", "notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...", "addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
"aboutThisVersion": "Über diese Version" "aboutThisVersion": "Über diese Version",
"baseModelSearchPlaceholder": "Basismodell suchen…",
"baseModelSuggested": "Vorschlag",
"baseModelNoMatch": "Keine passenden Basismodelle"
}, },
"notes": { "notes": {
"saved": "Notizen erfolgreich gespeichert", "saved": "Notizen erfolgreich gespeichert",

View File

@@ -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",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "Notas adicionales", "additionalNotes": "Notas adicionales",
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea", "notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
"addNotesPlaceholder": "Añade tus notas aquí...", "addNotesPlaceholder": "Añade tus notas aquí...",
"aboutThisVersion": "Acerca de esta versión" "aboutThisVersion": "Acerca de esta versión",
"baseModelSearchPlaceholder": "Buscar modelo base…",
"baseModelSuggested": "Sugerido",
"baseModelNoMatch": "No hay modelos base que coincidan"
}, },
"notes": { "notes": {
"saved": "Notas guardadas exitosamente", "saved": "Notas guardadas exitosamente",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "Notes supplémentaires", "additionalNotes": "Notes supplémentaires",
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne", "notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
"addNotesPlaceholder": "Ajoutez vos notes ici...", "addNotesPlaceholder": "Ajoutez vos notes ici...",
"aboutThisVersion": "À propos de cette version" "aboutThisVersion": "À propos de cette version",
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
"baseModelSuggested": "Suggéré",
"baseModelNoMatch": "Aucun modèle de base correspondant"
}, },
"notes": { "notes": {
"saved": "Notes sauvegardées avec succès", "saved": "Notes sauvegardées avec succès",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "הערות נוספות", "additionalNotes": "הערות נוספות",
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה", "notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...", "addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
"aboutThisVersion": "אודות גרסה זו" "aboutThisVersion": "אודות גרסה זו",
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
"baseModelSuggested": "מוצע",
"baseModelNoMatch": "אין מודלי בסיס תואמים"
}, },
"notes": { "notes": {
"saved": "הערות נשמרו בהצלחה", "saved": "הערות נשמרו בהצלחה",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "追加メモ", "additionalNotes": "追加メモ",
"notesHint": "Enterで保存、Shift+Enterで改行", "notesHint": "Enterで保存、Shift+Enterで改行",
"addNotesPlaceholder": "メモをここに追加...", "addNotesPlaceholder": "メモをここに追加...",
"aboutThisVersion": "このバージョンについて" "aboutThisVersion": "このバージョンについて",
"baseModelSearchPlaceholder": "ベースモデルを検索…",
"baseModelSuggested": "おすすめ",
"baseModelNoMatch": "該当するベースモデルがありません"
}, },
"notes": { "notes": {
"saved": "メモが正常に保存されました", "saved": "メモが正常に保存されました",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "추가 메모", "additionalNotes": "추가 메모",
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈", "notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
"addNotesPlaceholder": "메모를 여기에 추가하세요...", "addNotesPlaceholder": "메모를 여기에 추가하세요...",
"aboutThisVersion": "이 버전에 대해" "aboutThisVersion": "이 버전에 대해",
"baseModelSearchPlaceholder": "베이스 모델 검색…",
"baseModelSuggested": "추천",
"baseModelNoMatch": "일치하는 베이스 모델 없음"
}, },
"notes": { "notes": {
"saved": "메모가 성공적으로 저장됨", "saved": "메모가 성공적으로 저장됨",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "Дополнительные заметки", "additionalNotes": "Дополнительные заметки",
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки", "notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"addNotesPlaceholder": "Добавьте ваши заметки здесь...", "addNotesPlaceholder": "Добавьте ваши заметки здесь...",
"aboutThisVersion": "Об этой версии" "aboutThisVersion": "Об этой версии",
"baseModelSearchPlaceholder": "Поиск базовой модели…",
"baseModelSuggested": "Предполагаемые",
"baseModelNoMatch": "Нет подходящих базовых моделей"
}, },
"notes": { "notes": {
"saved": "Заметки успешно сохранены", "saved": "Заметки успешно сохранены",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "附加备注", "additionalNotes": "附加备注",
"notesHint": "回车保存Shift+回车换行", "notesHint": "回车保存Shift+回车换行",
"addNotesPlaceholder": "在此添加你的备注...", "addNotesPlaceholder": "在此添加你的备注...",
"aboutThisVersion": "关于此版本" "aboutThisVersion": "关于此版本",
"baseModelSearchPlaceholder": "搜索基础模型…",
"baseModelSuggested": "推荐",
"baseModelNoMatch": "没有匹配的基础模型"
}, },
"notes": { "notes": {
"saved": "备注保存成功", "saved": "备注保存成功",

View File

@@ -1347,7 +1347,10 @@
"additionalNotes": "附加備註", "additionalNotes": "附加備註",
"notesHint": "按 Enter 儲存Shift+Enter 換行", "notesHint": "按 Enter 儲存Shift+Enter 換行",
"addNotesPlaceholder": "在此新增備註...", "addNotesPlaceholder": "在此新增備註...",
"aboutThisVersion": "關於此版本" "aboutThisVersion": "關於此版本",
"baseModelSearchPlaceholder": "搜尋基礎模型…",
"baseModelSuggested": "推薦",
"baseModelNoMatch": "沒有符合的基礎模型"
}, },
"notes": { "notes": {
"saved": "備註已儲存", "saved": "備註已儲存",

View File

@@ -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 {

View File

@@ -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 ──────────────────────────────────────────
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// ── Build search widget DOM ───────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Search input row
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);
// Dropdown list
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// ── Render ────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
const lowerFilter = (filterText || '').toLowerCase().trim();
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// 1. Suggested section (filename-inferred, filtered by search)
let suggestedToShow = inferredModels;
if (lowerFilter) {
suggestedToShow = inferredModels.filter(m =>
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
);
}
});
// Replace content with search widget
baseModelContent.style.display = 'none'; baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// Hide edit button during editing
editBtn.style.display = 'none'; editBtn.style.display = 'none';
baseModelDisplay.insertBefore(wrapper, editBtn);
searchInput.focus();
// Focus the dropdown // ── Cleanup ───────────────────────────────────────────────────────────
dropdown.focus(); function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
// Handle dropdown change baseModelDisplay.removeChild(wrapper);
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
} }
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// 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();
} }
}); });
}); });