Files
mcDandy-more_math/web/autocomplete.js
T

418 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FUNCTIONS, KEYWORDS, CONSTANTS, FUNCTION_META } from "./inbuilt_symbols.js";
const AUTOCOMPLETE_STYLE_ID = "mrmth-script-autocomplete-styles";
function ensureAutocompleteStyles() {
if (document.getElementById(AUTOCOMPLETE_STYLE_ID)) return;
const style = document.createElement("style");
style.id = AUTOCOMPLETE_STYLE_ID;
style.textContent = `
.mrmth-autocomplete {
position: absolute;
z-index: 20;
min-width: 180px;
max-width: min(420px, 100%);
max-height: 220px;
overflow-y: auto;
margin: 0;
padding: 4px 0;
list-style: none;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(18, 20, 24, 0.97);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
font-family: monospace;
font-size: 12px;
}
.mrmth-autocomplete-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 5px 10px;
cursor: pointer;
color: #ecf0f1;
}
.mrmth-autocomplete-item.is-selected {
background: rgba(142, 68, 173, 0.35);
}
.mrmth-autocomplete-item .mrmth-ac-kind {
flex-shrink: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9aa0a6;
}
.mrmth-autocomplete-item .mrmth-ac-name {
color: #c39bd3;
font-weight: 600;
}
.mrmth-autocomplete-item.is-keyword .mrmth-ac-name {
color: #e74c3c;
}
.mrmth-autocomplete-item.is-constant .mrmth-ac-name {
color: #f1c40f;
}
.mrmth-autocomplete-item .mrmth-ac-hint {
color: #7f8c8d;
font-size: 11px;
white-space: nowrap;
}
`;
document.head.appendChild(style);
}
function isCursorInStringOrComment(text, cursor) {
let inString = false;
let stringChar = "";
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < cursor; i++) {
const ch = text[i];
const next = text[i + 1];
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
if (ch === "\\") {
i++;
continue;
}
if (ch === stringChar) inString = false;
continue;
}
if (ch === "#") {
inLineComment = true;
continue;
}
if (ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (ch === '"' || ch === "'") {
inString = true;
stringChar = ch;
}
}
return inString || inBlockComment;
}
function getWordPrefix(text, cursor) {
const before = text.slice(0, cursor);
const match = before.match(/[a-zA-Z_][a-zA-Z0-9_]*$/);
if (!match) return null;
return { word: match[0], start: cursor - match[0].length };
}
function formatArgHint(meta) {
if (!meta) return "";
if (meta.maxArgs === null) {
if (meta.minArgs <= 1) return "(…)";
return `(${meta.minArgs}+ args)`;
}
if (meta.minArgs === meta.maxArgs) {
if (meta.minArgs === 0) return "()";
if (meta.minArgs === 1) return "(x)";
return `(${meta.minArgs} args)`;
}
return `(${meta.minArgs}–${meta.maxArgs} args)`;
}
function buildSuggestions(prefix) {
const lower = prefix.toLowerCase();
const items = [];
for (const name of FUNCTIONS) {
if (!name.toLowerCase().startsWith(lower)) continue;
const meta = FUNCTION_META[name];
items.push({
name,
kind: "function",
hint: meta?.snippet || `${name}()`,
detail: formatArgHint(meta),
insertText: meta?.snippet || `${name}()`,
});
}
for (const name of KEYWORDS) {
if (!name.toLowerCase().startsWith(lower)) continue;
items.push({
name,
kind: "keyword",
hint: name,
detail: "",
insertText: name,
});
}
for (const name of CONSTANTS) {
if (!name.toLowerCase().startsWith(lower)) continue;
items.push({
name,
kind: "constant",
hint: name,
detail: "",
insertText: name,
});
}
items.sort((a, b) => {
const kindOrder = { function: 0, constant: 1, keyword: 2 };
const ka = kindOrder[a.kind] ?? 9;
const kb = kindOrder[b.kind] ?? 9;
if (ka !== kb) return ka - kb;
return a.name.localeCompare(b.name);
});
return items.slice(0, 40);
}
function getCaretCoordinates(textarea, position) {
const style = window.getComputedStyle(textarea);
const div = document.createElement("div");
const props = [
"fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
"letterSpacing", "textTransform", "wordSpacing", "textIndent",
"whiteSpace", "wordBreak", "overflowWrap", "paddingTop", "paddingRight",
"paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth",
"borderBottomWidth", "borderLeftWidth", "boxSizing",
];
div.style.position = "absolute";
div.style.visibility = "hidden";
div.style.whiteSpace = "pre-wrap";
div.style.overflowWrap = style.overflowWrap;
div.style.wordBreak = style.wordBreak;
div.style.width = `${textarea.clientWidth}px`;
for (const prop of props) {
div.style[prop] = style[prop];
}
const text = textarea.value.substring(0, position);
const span = document.createElement("span");
span.textContent = text;
div.appendChild(span);
const marker = document.createElement("span");
marker.textContent = textarea.value.substring(position) || ".";
div.appendChild(marker);
document.body.appendChild(div);
const top = marker.offsetTop;
const left = marker.offsetLeft;
div.remove();
return { top, left };
}
/**
* Attach identifier autocomplete to a script textarea.
* @param {HTMLTextAreaElement} textarea
* @param {HTMLElement} containerEl - positioned parent (e.g. .mrmth-editor-container)
* @returns {{ destroy: () => void, close: () => void }}
*/
export function attachAutocomplete(textarea, containerEl) {
ensureAutocompleteStyles();
const dropdown = document.createElement("ul");
dropdown.className = "mrmth-autocomplete";
dropdown.hidden = true;
containerEl.appendChild(dropdown);
let items = [];
let selectedIndex = 0;
let prefixInfo = null;
let open = false;
const close = () => {
open = false;
dropdown.hidden = true;
dropdown.innerHTML = "";
items = [];
selectedIndex = 0;
prefixInfo = null;
};
const render = () => {
dropdown.innerHTML = "";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = `mrmth-autocomplete-item is-${item.kind}${index === selectedIndex ? " is-selected" : ""}`;
li.dataset.index = String(index);
const left = document.createElement("span");
left.className = "mrmth-ac-name";
left.textContent = item.name;
const right = document.createElement("span");
right.className = "mrmth-ac-hint";
right.textContent = item.detail || item.hint;
const kind = document.createElement("span");
kind.className = "mrmth-ac-kind";
kind.textContent = item.kind;
li.appendChild(left);
li.appendChild(right);
li.appendChild(kind);
li.addEventListener("mousedown", (e) => {
e.preventDefault();
selectedIndex = index;
applySelected();
});
dropdown.appendChild(li);
});
};
const positionDropdown = () => {
if (!prefixInfo) return;
const coords = getCaretCoordinates(textarea, prefixInfo.start);
dropdown.style.left = `${coords.left}px`;
dropdown.style.top = `${coords.top + 18}px`;
};
const openWith = (nextItems, nextPrefix) => {
if (!nextItems.length) {
close();
return;
}
items = nextItems;
selectedIndex = 0;
prefixInfo = nextPrefix;
open = true;
dropdown.hidden = false;
render();
positionDropdown();
};
const applySelected = () => {
if (!open || !prefixInfo || !items[selectedIndex]) return;
const item = items[selectedIndex];
const before = textarea.value.slice(0, prefixInfo.start);
const after = textarea.value.slice(textarea.selectionEnd);
const insert = item.insertText;
textarea.value = `${before}${insert}${after}`;
const cursorPos = before.length + insert.length;
if (item.kind === "function" && insert.endsWith("()")) {
textarea.selectionStart = textarea.selectionEnd = cursorPos - 1;
} else {
textarea.selectionStart = textarea.selectionEnd = cursorPos;
}
textarea.dispatchEvent(new Event("input", { bubbles: true }));
close();
};
const maybeOpen = (force = false) => {
const cursor = textarea.selectionStart;
if (cursor !== textarea.selectionEnd) {
close();
return;
}
if (isCursorInStringOrComment(textarea.value, cursor)) {
close();
return;
}
const info = getWordPrefix(textarea.value, cursor);
if (!info || (!force && info.word.length < 1)) {
close();
return;
}
const suggestions = buildSuggestions(info.word);
if (!suggestions.length) {
close();
return;
}
openWith(suggestions, info);
};
const onInput = () => {
if (!open) {
const info = getWordPrefix(textarea.value, textarea.selectionStart);
if (info && info.word.length >= 2) {
maybeOpen(false);
}
return;
}
maybeOpen(false);
};
const onKeyDown = (e) => {
if (e.ctrlKey && e.key === " ") {
e.preventDefault();
maybeOpen(true);
return;
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % items.length;
render();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
render();
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
applySelected();
return;
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
};
const onBlur = () => {
setTimeout(close, 120);
};
const onScroll = () => {
if (open) positionDropdown();
};
textarea.addEventListener("input", onInput);
textarea.addEventListener("keydown", onKeyDown);
textarea.addEventListener("blur", onBlur);
textarea.addEventListener("scroll", onScroll);
return {
close,
destroy() {
close();
dropdown.remove();
textarea.removeEventListener("input", onInput);
textarea.removeEventListener("keydown", onKeyDown);
textarea.removeEventListener("blur", onBlur);
textarea.removeEventListener("scroll", onScroll);
},
};
}
export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };