// Per-token tag autocomplete for comma-separated tag inputs, shared by the // upload and manage pages. Suggestions come from /tags.json (regenerated on // every build), so new tags appear on the next load. Only the token after the // last comma is completed; earlier tags in the field are left untouched. // // Usage: add data-tagcomplete to an , or call TagComplete.attach(input) // for inputs created dynamically. TagComplete.invalidate() drops the cached // tag list after an edit that may have introduced new tags. (function () { let tags = null; // cached list of tag names let inflight = null; const menu = document.createElement("div"); menu.className = "tagac-menu"; menu.style.display = "none"; menu.setAttribute("role", "listbox"); document.body.appendChild(menu); let items = []; // currently shown suggestions let sel = -1; // highlighted index function loadTags() { if (tags) return Promise.resolve(tags); if (inflight) return inflight; inflight = fetch("/tags.json", { headers: { Accept: "application/json" } }) .then((r) => (r.ok ? r.json() : [])) .then((list) => { tags = (list || []).map((t) => t.tag); return tags; }) .catch(() => { tags = []; return tags; }); return inflight; } // The token being typed is the text after the last comma. function lastToken(value) { return value.slice(value.lastIndexOf(",") + 1).replace(/^\s+/, ""); } function replaceLastToken(value, tag) { const i = value.lastIndexOf(","); const head = i < 0 ? "" : value.slice(0, i + 1) + " "; return head + tag + ", "; } function presentTags(value) { return value .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); } function hide() { menu.style.display = "none"; sel = -1; items = []; } function highlight() { [...menu.children].forEach((c, i) => c.classList.toggle("sel", i === sel)); if (sel >= 0 && menu.children[sel]) { menu.children[sel].scrollIntoView({ block: "nearest" }); } } function render(input) { const token = lastToken(input.value).toLowerCase(); if (!token) { hide(); return; } const present = new Set(presentTags(input.value)); items = (tags || []) .filter((t) => t.toLowerCase().includes(token) && !present.has(t.toLowerCase())) .slice(0, 8); if (items.length === 0) { hide(); return; } sel = 0; menu.innerHTML = ""; items.forEach((t, idx) => { const row = document.createElement("div"); row.className = "tagac-item" + (idx === 0 ? " sel" : ""); row.setAttribute("role", "option"); row.textContent = t; // mousedown (not click) so it fires before the input's blur. row.addEventListener("mousedown", (e) => { e.preventDefault(); choose(input, idx); }); menu.appendChild(row); }); const r = input.getBoundingClientRect(); menu.style.left = window.scrollX + r.left + "px"; menu.style.top = window.scrollY + r.bottom + "px"; menu.style.width = r.width + "px"; menu.style.display = "block"; } function choose(input, idx) { if (idx < 0 || idx >= items.length) return; input.value = replaceLastToken(input.value, items[idx]); hide(); input.focus(); input.dispatchEvent(new Event("input", { bubbles: true })); } function attach(input) { if (!input || input.dataset.tagacBound) return; input.dataset.tagacBound = "1"; input.setAttribute("autocomplete", "off"); input.addEventListener("focus", () => loadTags().then(() => render(input))); input.addEventListener("input", () => loadTags().then(() => render(input))); input.addEventListener("keydown", (e) => { if (menu.style.display === "none" || items.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); sel = (sel + 1) % items.length; highlight(); } else if (e.key === "ArrowUp") { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; highlight(); } else if (e.key === "Enter" || e.key === "Tab") { if (sel >= 0) { e.preventDefault(); choose(input, sel); } } else if (e.key === "Escape") { hide(); } }); // Delay so a mousedown on the menu is processed before hiding. input.addEventListener("blur", () => setTimeout(hide, 120)); } window.TagComplete = { attach, invalidate() { tags = null; inflight = null; }, }; document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll("input[data-tagcomplete]").forEach(attach); }); })();