diff --git a/l4d2web/l4d2web/static/js/console-autocomplete.js b/l4d2web/l4d2web/static/js/console-autocomplete.js new file mode 100644 index 0000000..3dd9ca0 --- /dev/null +++ b/l4d2web/l4d2web/static/js/console-autocomplete.js @@ -0,0 +1,183 @@ +// console-autocomplete.js +// Vanilla dropdown autocomplete for [data-console-form] inputs. +// Reads ranked completions from window.__rankVocab (loaded via +// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events. +// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those). +// +// First-token only: the dropdown is hidden as soon as the cursor +// is past the first space in the input. + +const VOCAB_URL = "/static/data/srccfg-vocab.json"; +const MAX_RENDERED = 8; +let vocabPromise = null; + +function loadVocab() { + if (vocabPromise) return vocabPromise; + vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" }) + .then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status))) + .catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; }); + return vocabPromise; +} + +function firstTokenSlice(value, caret) { + // Returns the substring [0, end-of-first-token) if the caret is + // within the first token; otherwise null. + const spaceIdx = value.indexOf(" "); + if (spaceIdx === -1) { + return { token: value, from: 0, to: value.length }; + } + if (caret > spaceIdx) return null; + return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx }; +} + +function bindConsoleAutocomplete(form) { + if (form.dataset.consoleAutocompleteBound === "true") return; + form.dataset.consoleAutocompleteBound = "true"; + + const input = form.querySelector("input[name='command']"); + if (!input) return; + + // --- Dropdown DOM (created lazily on first show) --- + let dropdown = null; + let items = []; // current ranked items + let highlightIdx = 0; // index of currently-highlighted row + let vocab = null; + + function ensureDropdown() { + if (dropdown) return dropdown; + dropdown = document.createElement("div"); + dropdown.className = "console-autocomplete-dropdown"; + dropdown.setAttribute("role", "listbox"); + dropdown.style.display = "none"; + document.body.appendChild(dropdown); + return dropdown; + } + + function position() { + if (!dropdown) return; + const rect = input.getBoundingClientRect(); + dropdown.style.left = `${rect.left + window.scrollX}px`; + dropdown.style.top = `${rect.bottom + window.scrollY}px`; + dropdown.style.minWidth = `${rect.width}px`; + } + + function close() { + if (!dropdown) return; + dropdown.style.display = "none"; + items = []; + highlightIdx = 0; + } + + function render() { + ensureDropdown(); + if (items.length === 0) { close(); return; } + const rows = items.slice(0, MAX_RENDERED).map((e, i) => { + const selected = i === highlightIdx ? " aria-selected='true'" : ""; + const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar"; + const desc = e.desc ? `${escapeHtml(e.desc)}` : ""; + return `
${escapeHtml(e.name)}${desc}
`; + }).join(""); + dropdown.innerHTML = rows; + dropdown.style.display = "block"; + position(); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); + } + + function acceptHighlighted() { + if (items.length === 0) return; + const chosen = items[highlightIdx]; + const slice = firstTokenSlice(input.value, input.selectionStart || 0); + if (!slice) return; + const before = input.value.slice(0, slice.from); + const after = input.value.slice(slice.to); + input.value = before + chosen.name + after; + // Place caret at end of inserted name + const caret = before.length + chosen.name.length; + input.setSelectionRange(caret, caret); + recompute(); + } + + function recompute() { + if (!vocab) return; + const slice = firstTokenSlice(input.value, input.selectionStart || 0); + if (!slice || !slice.token) { close(); return; } + items = window.__rankVocab(slice.token, vocab); + if (items.length === 0) { close(); return; } + highlightIdx = 0; + render(); + } + + // --- Lazy vocab fetch on first focus --- + input.addEventListener("focus", async () => { + if (!vocab) { + vocab = await loadVocab(); + } + }, { once: true }); + + input.addEventListener("input", () => { + if (!vocab) return; // fetch may not have resolved yet; next input will recompute + recompute(); + }); + + input.addEventListener("keydown", (event) => { + if (event.key === "Tab" && !event.shiftKey) { + if (items.length > 0) { + event.preventDefault(); + acceptHighlighted(); + } + } else if (event.key === "Tab" && event.shiftKey) { + if (items.length > 0) { + event.preventDefault(); + highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED)) + % Math.min(items.length, MAX_RENDERED); + render(); + } + } else if (event.key === "Escape") { + if (dropdown && dropdown.style.display !== "none") { + event.preventDefault(); + close(); + } + } + // ArrowUp/ArrowDown/Enter intentionally NOT handled here. + }); + + input.addEventListener("blur", () => { + // Delay close so a click on a dropdown row can fire first. + setTimeout(close, 100); + }); + + // Mouse click on a row → accept that row. + document.addEventListener("mousedown", (event) => { + if (!dropdown || dropdown.style.display === "none") return; + const row = event.target.closest(".console-autocomplete-row"); + if (!row || !dropdown.contains(row)) return; + event.preventDefault(); + highlightIdx = parseInt(row.dataset.idx, 10) || 0; + acceptHighlighted(); + input.focus(); + }); + + // HTMX form submission clears the input; close on submit. + form.addEventListener("htmx:beforeRequest", close); + + // Reposition on resize/scroll while dropdown is open. + window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); }); + window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true); +} + +function bindAll(root) { + if (!root) return; + const scope = root.matches && root.matches("[data-console-form]") ? [root] : []; + if (root.querySelectorAll) { + root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el)); + } + scope.forEach(bindConsoleAutocomplete); +} + +document.addEventListener("DOMContentLoaded", () => bindAll(document)); +document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));