diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index 67dc5e5..b800220 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -178,14 +178,6 @@ document.addEventListener('DOMContentLoaded', () => { suggestionsList.appendChild(group); } suggestionsSection.style.display = total > 0 ? '' : 'none'; - - // Suggestions load async after the modal opens, so the autocomplete dropdown - // may have already positioned itself downward (before the section was visible). - // Re-run positioning so it flips above the input now that suggestions occupy - // the space below. - if (tagAutocomplete && tagAutocomplete.classList.contains('active')) { - positionAutocomplete(); - } } async function loadSuggestions(imageId) { @@ -626,44 +618,20 @@ document.addEventListener('DOMContentLoaded', () => { // --------------------------- // Autocomplete helpers // --------------------------- - function hideAutocomplete() { + // The autocomplete area is an always-reserved block below the input + // (see .tag-autocomplete in style.css), so there's nothing to show/hide — + // the area is either populated with matches, the empty-state message, or a + // muted placeholder when there's no active query. + function clearAutocomplete(placeholder = '') { if (tagAutocomplete) { - tagAutocomplete.classList.remove('active'); - tagAutocomplete.innerHTML = ''; + tagAutocomplete.innerHTML = placeholder + ? `
${escapeHtml(placeholder)}
` + : ''; } autocompleteItems = []; autocompleteSelectedIndex = -1; } - function positionAutocomplete() { - if (!tagAutocomplete || !tagInput) return; - const rect = tagInput.getBoundingClientRect(); - const gap = 4; - tagAutocomplete.style.left = `${rect.left}px`; - tagAutocomplete.style.width = `${rect.width}px`; - - // Measure the dropdown height. Requires the element to be visible - // (callers add the `active` class before invoking this function). - const dropdownHeight = tagAutocomplete.offsetHeight; - - // If the Suggestions section sits below the input and the default - // downward dropdown would overlap it, flip the dropdown above the - // input so the user can still see their suggestion chips. - let flipUp = false; - if (suggestionsSection && suggestionsSection.offsetParent !== null) { - const suggestionsRect = suggestionsSection.getBoundingClientRect(); - const wouldOverlap = (rect.bottom + gap + dropdownHeight) > suggestionsRect.top; - const fitsAbove = (rect.top - gap - dropdownHeight) >= 0; - if (wouldOverlap && fitsAbove) flipUp = true; - } - - if (flipUp) { - tagAutocomplete.style.top = `${rect.top - gap - dropdownHeight}px`; - } else { - tagAutocomplete.style.top = `${rect.bottom + gap}px`; - } - } - function renderAutocomplete(tags) { if (!tagAutocomplete) return; autocompleteItems = tags; @@ -671,8 +639,6 @@ document.addEventListener('DOMContentLoaded', () => { if (tags.length === 0) { tagAutocomplete.innerHTML = '
No matching tags
'; - tagAutocomplete.classList.add('active'); - positionAutocomplete(); return; } @@ -684,8 +650,6 @@ document.addEventListener('DOMContentLoaded', () => { ${t.kind || 'user'} `; }).join(''); - tagAutocomplete.classList.add('active'); - positionAutocomplete(); } function selectAutocompleteItem(index) { @@ -702,13 +666,13 @@ document.addEventListener('DOMContentLoaded', () => { // Exclude system-managed tag kinds from autocomplete (archive, post, source) const excludeKinds = 'archive,post,source'; const url = query - ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}` - : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`; + ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=20&exclude_kind=${excludeKinds}` + : `/api/tags/search?limit=20&exclude_kind=${excludeKinds}`; const r = await fetch(url); const j = await r.json(); renderAutocomplete(j.tags || []); } catch { - hideAutocomplete(); + clearAutocomplete(); } } @@ -717,24 +681,19 @@ document.addEventListener('DOMContentLoaded', () => { const id = getEditorImageId(); const name = (tagInput?.value || '').trim(); if (!id || !name) return; - hideAutocomplete(); await addTag(id, name); if (tagInput) tagInput.value = ''; + // Reset the reserved area to the top-tags view so it's ready for the next add. + fetchAutocomplete(''); } if (tagInput) { - // Show autocomplete on focus + // Refresh top tags on focus so the area reflects the latest input value. tagInput.addEventListener('focus', () => { - const val = tagInput.value.trim(); - fetchAutocomplete(val); + fetchAutocomplete(tagInput.value.trim()); }); - // Hide autocomplete on blur (with delay for click) - tagInput.addEventListener('blur', () => { - setTimeout(hideAutocomplete, 200); - }); - - // Search as user types + // Search as user types. tagInput.addEventListener('input', () => { clearTimeout(autocompleteDebounce); autocompleteDebounce = setTimeout(() => { @@ -742,9 +701,9 @@ document.addEventListener('DOMContentLoaded', () => { }, 150); }); - // Keyboard navigation + // Keyboard navigation — active whenever the reserved area has items. tagInput.addEventListener('keydown', (e) => { - if (!tagAutocomplete?.classList.contains('active')) return; + if (autocompleteItems.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); @@ -762,15 +721,16 @@ document.addEventListener('DOMContentLoaded', () => { submitTag(); } } else if (e.key === 'Escape') { - hideAutocomplete(); + selectAutocompleteItem(-1); } }); } - // Click on autocomplete item - use mousedown to fire before blur + // Click an autocomplete item to select and submit it. Use mousedown so the + // interaction still fires even though the input is losing focus. if (tagAutocomplete) { tagAutocomplete.addEventListener('mousedown', (e) => { - e.preventDefault(); // Prevent blur from firing + e.preventDefault(); e.stopPropagation(); const item = e.target.closest('.tag-autocomplete-item'); if (!item) return; @@ -832,8 +792,11 @@ document.addEventListener('DOMContentLoaded', () => { // NEW: load tags for this image into the modal editor setEditorImageId(imageId); + if (tagInput) tagInput.value = ''; loadTags(imageId); loadSeriesInfo(imageId); + // Prefill the reserved autocomplete area with top tags so it's never empty. + fetchAutocomplete(''); } function openModal(index) { @@ -858,9 +821,11 @@ document.addEventListener('DOMContentLoaded', () => { resetZoom(); // clear tag UI setEditorImageId(''); + if (tagInput) tagInput.value = ''; renderTags([]); renderProvenance([]); clearSuggestions(); + clearAutocomplete(); hideSeriesInfo(); hideAddToSeries(); history.replaceState({}, '', window.location.pathname + window.location.search); diff --git a/app/static/style.css b/app/static/style.css index cb2c7eb..05391fa 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1076,6 +1076,24 @@ header { .tag-autocomplete.active { display: block; } +/* Inline variant: the modal uses a reserved area that sits below the add-tag + input instead of floating over provenance/suggestions. Height fits ~5 rows; + overflow scrolls when more matches are loaded. */ +.tag-autocomplete-inline { + display: block; + position: static; + margin-top: 0.5rem; + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + height: 11.5rem; + max-height: none; + min-width: 0; + max-width: none; + overflow-y: auto; + box-shadow: none; + z-index: auto; +} .tag-autocomplete-item { padding: 0.5rem 0.75rem; cursor: pointer; @@ -1083,6 +1101,7 @@ header { align-items: center; gap: 0.5rem; font-size: 0.9rem; + line-height: 1.2; color: var(--text); border-bottom: 1px solid rgba(255, 255, 255, 0.05); } diff --git a/app/templates/_gallery_modal.html b/app/templates/_gallery_modal.html index 81bbd66..961bfd3 100644 --- a/app/templates/_gallery_modal.html +++ b/app/templates/_gallery_modal.html @@ -58,10 +58,10 @@
-
+