Add tag autocomplete to the upload/edit tag inputs
All checks were successful
build-and-push / docker (push) Successful in 27s
All checks were successful
build-and-push / docker (push) Successful in 27s
Suggests existing library tags matching the token being typed (after the last comma), with click or arrow-key/Enter selection; excludes tags already entered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a2e08ecd03
commit
d3410e19ec
2 changed files with 75 additions and 0 deletions
|
|
@ -335,6 +335,8 @@ function setupEventListeners() {
|
|||
});
|
||||
|
||||
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
|
||||
setupTagAutocomplete(document.getElementById('fileTags'));
|
||||
setupTagAutocomplete(document.getElementById('editTags'));
|
||||
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
||||
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
|
||||
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
|
||||
|
|
@ -932,6 +934,69 @@ async function handleDeleteModel() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------- utils
|
||||
// All distinct tags currently in the library, sorted.
|
||||
function knownTags() {
|
||||
const set = new Set();
|
||||
models.forEach((m) => (m.tags || []).forEach((t) => set.add(t)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Autocomplete for a comma-separated tag input: suggests existing tags that
|
||||
// match the token currently being typed (after the last comma).
|
||||
function setupTagAutocomplete(input) {
|
||||
if (!input) return;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'tag-suggest';
|
||||
box.style.display = 'none';
|
||||
input.parentNode.style.position = 'relative';
|
||||
input.parentNode.appendChild(box);
|
||||
let active = -1;
|
||||
|
||||
const parts = () => input.value.split(',');
|
||||
const currentToken = () => parts().pop().trim().toLowerCase();
|
||||
const hide = () => { box.style.display = 'none'; active = -1; };
|
||||
|
||||
function choose(tag) {
|
||||
const p = parts().map((x) => x.trim());
|
||||
p[p.length - 1] = tag;
|
||||
input.value = p.join(', ') + ', ';
|
||||
hide();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tok = currentToken();
|
||||
if (!tok) return hide();
|
||||
const taken = new Set(parts().map((x) => x.trim().toLowerCase()));
|
||||
const matches = knownTags().filter((t) => t.toLowerCase().includes(tok) && !taken.has(t.toLowerCase())).slice(0, 8);
|
||||
if (!matches.length) return hide();
|
||||
active = -1;
|
||||
box.innerHTML = matches.map((t) => `<div class="tag-suggest-item" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</div>`).join('');
|
||||
box.style.top = `${input.offsetTop + input.offsetHeight + 2}px`;
|
||||
box.style.left = `${input.offsetLeft}px`;
|
||||
box.style.width = `${input.offsetWidth}px`;
|
||||
box.style.display = '';
|
||||
}
|
||||
|
||||
input.addEventListener('input', render);
|
||||
input.addEventListener('focus', render);
|
||||
input.addEventListener('blur', () => setTimeout(hide, 150));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = [...box.querySelectorAll('.tag-suggest-item')];
|
||||
if (box.style.display === 'none' || !items.length) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; }
|
||||
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(items[active].dataset.tag); return; }
|
||||
else if (e.key === 'Escape') { hide(); return; }
|
||||
else return;
|
||||
items.forEach((it, i) => it.classList.toggle('active', i === active));
|
||||
});
|
||||
box.addEventListener('mousedown', (e) => {
|
||||
const it = e.target.closest('.tag-suggest-item');
|
||||
if (it) { e.preventDefault(); choose(it.dataset.tag); }
|
||||
});
|
||||
}
|
||||
|
||||
function splitTags(value) {
|
||||
return value.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,3 +269,13 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
.license-name { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.license-name em { color: var(--text-dim); font-style: normal; }
|
||||
.license-select { width: auto; flex-shrink: 0; padding: 6px 10px; font-size: 13px; }
|
||||
|
||||
/* ---- tag autocomplete ---- */
|
||||
.tag-suggest {
|
||||
position: absolute; z-index: 130;
|
||||
background: var(--surface-2); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); max-height: 200px; overflow-y: auto;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.tag-suggest-item { padding: 7px 11px; font-size: 13px; cursor: pointer; color: var(--text-muted); }
|
||||
.tag-suggest-item:hover, .tag-suggest-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue