diff --git a/l4d2web/l4d2web/static/js/console-autocomplete.js b/l4d2web/l4d2web/static/js/console-autocomplete.js index 528133e..846002f 100644 --- a/l4d2web/l4d2web/static/js/console-autocomplete.js +++ b/l4d2web/l4d2web/static/js/console-autocomplete.js @@ -6,15 +6,25 @@ // // First-token only: the dropdown is hidden as soon as the cursor // is past the first space in the input. +// +// Listeners are delegated to the form (not the input) so the input +// element can be swapped via HTMX without breaking autocomplete. +// Document- and window-level handlers are module-scoped and operate +// on the currently-open dropdown via the activeBinding pointer. const VOCAB_URL = "/static/data/srccfg-vocab.json"; const MAX_RENDERED = 8; + +// Module-scoped state. let vocabPromise = null; +let vocab = null; +let activeBinding = 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))) + .then(v => { vocab = v; return v; }) .catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; }); return vocabPromise; } @@ -30,18 +40,45 @@ function firstTokenSlice(value, caret) { return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx }; } +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); +} + +// Module-scoped document/window listeners installed once. +// Operate on whichever binding has its dropdown currently open. +document.addEventListener("mousedown", (event) => { + if (!activeBinding) return; + const dropdown = activeBinding.getDropdown(); + if (!dropdown || dropdown.style.display === "none") return; + const row = event.target.closest(".console-autocomplete-row"); + if (!row || !dropdown.contains(row)) return; + event.preventDefault(); + activeBinding.acceptRow(row); +}); + +window.addEventListener("resize", () => { + if (activeBinding) activeBinding.position(); +}); + +window.addEventListener("scroll", () => { + if (activeBinding) activeBinding.position(); +}, true); + 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) --- + // Per-binding state. let dropdown = null; - let items = []; // current ranked items - let highlightIdx = 0; // index of currently-highlighted row - let vocab = null; + let items = []; + let highlightIdx = 0; + let focusHandledOnce = false; + + function getInput() { + return form.querySelector("input[name='command']"); + } function ensureDropdown() { if (dropdown) return dropdown; @@ -54,7 +91,8 @@ function bindConsoleAutocomplete(form) { } function position() { - if (!dropdown) return; + const input = getInput(); + if (!input || !dropdown) return; const rect = input.getBoundingClientRect(); dropdown.style.left = `${rect.left + window.scrollX}px`; dropdown.style.top = `${rect.bottom + window.scrollY}px`; @@ -62,10 +100,14 @@ function bindConsoleAutocomplete(form) { } function close() { - if (!dropdown) return; + if (!dropdown) { + if (activeBinding === binding) activeBinding = null; + return; + } dropdown.style.display = "none"; items = []; highlightIdx = 0; + if (activeBinding === binding) activeBinding = null; } function render() { @@ -79,38 +121,41 @@ function bindConsoleAutocomplete(form) { }).join(""); dropdown.innerHTML = rows; dropdown.style.display = "block"; + activeBinding = binding; position(); } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, c => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - }[c])); - } - function acceptHighlighted() { if (items.length === 0) return; + const input = getInput(); + if (!input) return; const chosen = items[highlightIdx]; const slice = firstTokenSlice(input.value, input.selectionStart || 0); if (!slice) return; - // If the first token is already exactly the chosen name, accepting it - // would be a no-op; close the dropdown so Tab feels responsive. if (slice.token === chosen.name) { close(); 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 acceptRow(rowEl) { + highlightIdx = parseInt(rowEl.dataset.idx, 10) || 0; + acceptHighlighted(); + const input = getInput(); + if (input) input.focus(); + } + function recompute() { if (!vocab) return; if (typeof window.__rankVocab !== "function") { console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?"); return; } + const input = getInput(); + if (!input) return; const slice = firstTokenSlice(input.value, input.selectionStart || 0); if (!slice || !slice.token) { close(); return; } items = window.__rankVocab(slice.token, vocab); @@ -119,22 +164,41 @@ function bindConsoleAutocomplete(form) { render(); } - // --- Lazy vocab fetch on first focus --- - input.addEventListener("focus", async () => { - if (!vocab) { - vocab = await loadVocab(); - // If the user already typed during the fetch, rank now so the - // dropdown doesn't appear to lag a keystroke behind on cold load. - if (vocab && document.activeElement === input) recompute(); - } - }, { once: true }); + // Binding object exposed to module-scope listeners. + // getDropdown() returns the current dropdown reference, which is + // assigned lazily by ensureDropdown(); the getter is needed because + // the dropdown variable's binding changes after construction. + const binding = { + getDropdown: () => dropdown, + position, + close, + acceptRow, + }; - input.addEventListener("input", () => { - if (!vocab) return; // fetch may not have resolved yet; next input will recompute + // Form-level event delegation. event.target.matches gates each + // handler to the command input, so the input element can be + // swapped via HTMX without rebinding. + + form.addEventListener("focusin", async (event) => { + if (!event.target.matches('input[name="command"]')) return; + if (focusHandledOnce) return; + focusHandledOnce = true; + if (!vocab) { + await loadVocab(); + // If the user typed during the fetch, rank now so the dropdown + // doesn't appear to lag a keystroke behind on cold load. + if (vocab && document.activeElement === event.target) recompute(); + } + }); + + form.addEventListener("input", (event) => { + if (!event.target.matches('input[name="command"]')) return; + if (!vocab) return; // fetch may not have resolved yet recompute(); }); - input.addEventListener("keydown", (event) => { + form.addEventListener("keydown", (event) => { + if (!event.target.matches('input[name="command"]')) return; if (event.key === "Tab" && !event.shiftKey) { if (items.length > 0) { event.preventDefault(); @@ -156,28 +220,14 @@ function bindConsoleAutocomplete(form) { // ArrowUp/ArrowDown/Enter intentionally NOT handled here. }); - input.addEventListener("blur", () => { + form.addEventListener("focusout", (event) => { + if (!event.target.matches('input[name="command"]')) return; // 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) {