feat(editor): add identifier autocomplete popup
Vocab loaded lazily from /static/data/<lang>-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.
This commit is contained in:
parent
e6fe701718
commit
3d3629f592
1 changed files with 188 additions and 0 deletions
|
|
@ -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 <input> or null.
|
||||
// Intentionally does NOT walk up to <body>: 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue