From 3d3629f5928e3d70890e5b984b763cc2ce5e1cda Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sat, 16 May 2026 20:42:03 +0200 Subject: [PATCH] feat(editor): add identifier autocomplete popup Vocab loaded lazily from /static/data/-vocab.json on first mount, cached in memory. Popup appears when the word fragment before the caret has >=2 word characters and matches the vocabulary. Prefix matches rank ahead of substring matches; popup shows up to 8 with scroll. Up/Down navigate, Tab/Enter accept, Esc dismisses. acceptCompletion uses instance.jar (not the captured closure) so runtime jar reassignment via setLanguage stays consistent. --- l4d2web/l4d2web/static/js/editor.js | 188 ++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/l4d2web/l4d2web/static/js/editor.js b/l4d2web/l4d2web/static/js/editor.js index 7424fbb..cadf868 100644 --- a/l4d2web/l4d2web/static/js/editor.js +++ b/l4d2web/l4d2web/static/js/editor.js @@ -17,6 +17,32 @@ bash: "bash", }; + const VOCAB_URLS = { + srccfg: "/static/data/srccfg-vocab.json", + }; + + const vocabCache = {}; + + async function loadVocab(lang) { + if (vocabCache[lang]) return vocabCache[lang]; + const url = VOCAB_URLS[lang]; + if (!url) return []; + try { + const r = await fetch(url); + if (!r.ok) return []; + const data = await r.json(); + const merged = [] + .concat(data.cvars || []) + .concat(data.commands || []); + vocabCache[lang] = merged; + return merged; + } catch (err) { + console.warn("[editor] vocab load failed for " + lang, err); + vocabCache[lang] = []; + return []; + } + } + function resolveAutoLanguage(filename) { if (!filename) return "plain"; const m = /\.([a-zA-Z0-9]+)$/.exec(filename); @@ -37,6 +63,65 @@ }; } + const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/; + + function getCaretContext(codeEl) { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + const range = sel.getRangeAt(0).cloneRange(); + if (!codeEl.contains(range.endContainer)) return null; + // Build the text from start of the editor up to the caret. + const pre = range.cloneRange(); + pre.selectNodeContents(codeEl); + pre.setEnd(range.endContainer, range.endOffset); + const textBefore = pre.toString(); + const m = WORD_BEFORE_CARET.exec(textBefore); + if (!m) return null; + return { + fragment: m[0], + rect: range.getBoundingClientRect(), + }; + } + + function filterVocab(vocab, fragment) { + const lower = fragment.toLowerCase(); + const prefix = []; + const substr = []; + for (const entry of vocab) { + const name = entry.name.toLowerCase(); + if (name.startsWith(lower)) prefix.push(entry); + else if (name.includes(lower)) substr.push(entry); + if (prefix.length + substr.length >= 50) break; + } + return prefix.concat(substr).slice(0, 50); + } + + function renderPopup(popup, items, activeIndex) { + popup.innerHTML = ""; + const visible = items.slice(0, 8); + visible.forEach((entry, i) => { + const li = document.createElement("li"); + li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : ""); + li.dataset.index = String(i); + const name = document.createElement("span"); + name.className = "name"; + name.textContent = entry.name; + li.appendChild(name); + if (entry.desc) { + const desc = document.createElement("span"); + desc.className = "desc"; + desc.textContent = "— " + entry.desc; + li.appendChild(desc); + } + popup.appendChild(li); + }); + } + + function positionPopup(popup, rect) { + popup.style.left = (window.scrollX + rect.left) + "px"; + popup.style.top = (window.scrollY + rect.bottom + 2) + "px"; + } + // For "auto" language: look for a filename input inside the nearest // enclosing dialog or .modal. Returns the or null. // Intentionally does NOT walk up to : an "auto" textarea @@ -84,6 +169,109 @@ attachOnUpdate(jar); + let popup = null; + let popupItems = []; + let popupActive = 0; + + function ensurePopup() { + if (popup) return popup; + popup = document.createElement("ul"); + popup.className = "editor-popup"; + popup.style.display = "none"; + document.body.appendChild(popup); + popup.addEventListener("mousedown", function (e) { + e.preventDefault(); // keep caret in editor + const li = e.target.closest(".editor-popup-item"); + if (!li) return; + acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]); + }); + return popup; + } + + function hidePopup() { + if (popup) popup.style.display = "none"; + popupItems = []; + } + + function acceptCompletion(entry) { + if (!entry) return; + const ctx = getCaretContext(code); + if (!ctx) { + hidePopup(); + return; + } + // Replace the trailing word fragment with the chosen identifier. + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + range.setStart(range.endContainer, range.endOffset - ctx.fragment.length); + range.deleteContents(); + range.insertNode(document.createTextNode(entry.name)); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + // Force CodeJar to re-highlight + emit onUpdate. + // Use instance.jar (not bare `jar` closure) for consistency with + // setLanguage's tear-down-and-remount — the jar reference can be + // swapped at runtime. + instance.jar.updateCode(instance.jar.toString()); + hidePopup(); + } + + async function refreshPopup() { + if (instance.language === "plain") { + hidePopup(); + return; + } + const ctx = getCaretContext(code); + if (!ctx) { + hidePopup(); + return; + } + const vocab = await loadVocab(instance.language); + if (!vocab.length) { + hidePopup(); + return; + } + const filtered = filterVocab(vocab, ctx.fragment); + if (!filtered.length) { + hidePopup(); + return; + } + popupItems = filtered; + popupActive = 0; + ensurePopup(); + renderPopup(popup, popupItems, popupActive); + positionPopup(popup, ctx.rect); + popup.style.display = ""; + } + + code.addEventListener("input", refreshPopup); + code.addEventListener("blur", function () { + // Defer hide so a popup click can still register. + setTimeout(hidePopup, 100); + }); + + code.addEventListener("keydown", function (e) { + if (!popup || popup.style.display === "none" || !popupItems.length) return; + if (e.key === "ArrowDown") { + popupActive = (popupActive + 1) % Math.min(popupItems.length, 8); + renderPopup(popup, popupItems, popupActive); + e.preventDefault(); + } else if (e.key === "ArrowUp") { + popupActive = + (popupActive - 1 + Math.min(popupItems.length, 8)) % + Math.min(popupItems.length, 8); + renderPopup(popup, popupItems, popupActive); + e.preventDefault(); + } else if (e.key === "Tab" || e.key === "Enter") { + acceptCompletion(popupItems[popupActive]); + e.preventDefault(); + } else if (e.key === "Escape") { + hidePopup(); + e.preventDefault(); + } + }); + const instance = { textarea, shell,