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