Files
mcDandy-more_math/web/autocomplete.js
T
2026-06-11 23:40:41 +02:00

664 lines
20 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-tooltip {
display: none; /* Řízeno přes JS */
min-width: 200px;
max-width: 320px;
margin-left: 8px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(24, 28, 36, 0.98);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
font-family: monospace;
font-size: 11px;
color: #b3b9c1;
line-height: 1.5;
pointer-events: auto;
}
.mrmth-autocomplete-layout {
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
position: fixed !important; /* Pozici drží POUZE tento společný obal! */
z-index: 10000 !important;
pointer-events: none;
}
.mrmth-autocomplete {
min-width: 180px;
max-height: 220px;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: 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;
}
`;
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 = style.whiteSpace === "none" ? "pre" : "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 = "\u200B";
div.appendChild(marker);
document.body.appendChild(div);
const top = marker.offsetTop;
const left = marker.offsetLeft;
div.remove();
return { top, left };
}
function getTextareaViewportScale(textarea) {
const rect = textarea.getBoundingClientRect();
const layoutHeight = textarea.offsetHeight;
if (layoutHeight <= 0) return 1;
return rect.height / layoutHeight;
}
function getViewportLineHeight(textarea, scale) {
const lh = parseFloat(window.getComputedStyle(textarea).lineHeight);
if (Number.isFinite(lh) && lh > 0) return lh * scale;
return 18 * scale;
}
function getCaretScreenPosition(textarea, position) {
const coords = getCaretCoordinates(textarea, position);
const rect = textarea.getBoundingClientRect();
const scale = getTextareaViewportScale(textarea);
const x = coords.left - textarea.scrollLeft;
const y = coords.top - textarea.scrollTop-6;
return {
left: rect.left + x * scale,
top: rect.top + y * scale,
scale,
};
}
/**
* Attach identifier autocomplete to a script textarea.
* @param {HTMLTextAreaElement} textarea
* @param {HTMLElement} [_containerEl] - unused; dropdown is portaled to document.body
* @returns {{ destroy: () => void, close: () => void }}
*/
export function attachAutocomplete(textarea, _containerEl) {
ensureAutocompleteStyles();
// 1. Deklarace prvků nového rozvržení
let layoutEl = null;
let listEl = null; // Toto plně nahradí tvůj původní samostatný "dropdown"
let tooltipEl = null;
// 2. Opravené vytvoření dropdownu, které se spustí ihned
function createDropdown() {
layoutEl = document.createElement("div");
layoutEl.className = "mrmth-autocomplete-layout";
layoutEl.style.position = "fixed";
layoutEl.style.zIndex = "10000";
layoutEl.style.display = "flex";
layoutEl.hidden = true;
listEl = document.createElement("ul");
listEl.className = "mrmth-autocomplete";
listEl.addEventListener("wheel", onDropdownWheel, { passive: false });
layoutEl.appendChild(listEl);
tooltipEl = document.createElement("div");
tooltipEl.className = "mrmth-autocomplete-tooltip";
tooltipEl.style.display = "none";
layoutEl.appendChild(tooltipEl);
document.body.appendChild(layoutEl);
}
function updateTooltip(selectedItem) {
if (!tooltipEl || !layoutEl) return;
const meta = FUNCTION_META[selectedItem?.name] || selectedItem;
if (!meta || !meta.description) {
tooltipEl.style.display = "none";
return;
}
let argsText = `Arguments: min ${meta.minArgs ?? 0}`;
if (meta.maxArgs !== undefined && meta.maxArgs !== null) {
argsText += `, max ${meta.maxArgs}`;
} else {
argsText += "+ (variadic)";
}
tooltipEl.innerHTML = `
<div class="mrmth-tt-title">${selectedItem.name}</div>
<div class="mrmth-tt-desc">${meta.description}</div>
<div class="mrmth-tt-args">${argsText}</div>
`;
tooltipEl.style.display = "block";
const rect = tooltipEl.getBoundingClientRect();
if (rect.right > window.innerWidth) {
layoutEl.style.flexDirection = "row-reverse";
tooltipEl.style.marginLeft = "0";
tooltipEl.style.marginRight = "8px";
} else {
layoutEl.style.flexDirection = "row";
tooltipEl.style.marginLeft = "8px";
tooltipEl.style.marginRight = "0";
}
}
const onDropdownWheel = (e) => {
e.stopPropagation();
const maxScroll = Math.max(0, listEl.scrollHeight - listEl.clientHeight);
const atTop = listEl.scrollTop <= 0;
const atBottom = listEl.scrollTop >= maxScroll;
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
e.preventDefault();
}
};
createDropdown();
let items = [];
let selectedIndex = 0;
let prefixInfo = null;
let open = false;
const close = () => {
open = false;
if (layoutEl) {
layoutEl.hidden = true;
layoutEl.style.visibility = "hidden";
}
if (listEl) listEl.innerHTML = "";
if (tooltipEl) tooltipEl.style.display = "none";
items = [];
selectedIndex = 0;
prefixInfo = null;
};
const openWith = (nextItems, nextPrefix) => {
if (!nextItems.length) {
close();
return;
}
items = nextItems;
selectedIndex = 0;
prefixInfo = nextPrefix;
open = true;
if (layoutEl) {
layoutEl.hidden = false;
layoutEl.style.display = "flex"; // ZNOVU ROZSVÍTÍ KRESLENÍ PŘI PSANÍ
}
render();
positionDropdown();
};
const scrollSelectedIntoView = () => {
const selected = layoutEl.querySelector(".mrmth-autocomplete-item.is-selected");
if (!selected) return;
const itemTop = selected.offsetTop;
const itemBottom = itemTop + selected.offsetHeight;
const viewTop = listEl.scrollTop;
const viewBottom = viewTop + listEl.clientHeight;
const pad = 2;
if (itemTop < viewTop + pad) {
listEl.scrollTop = Math.max(0, itemTop - pad);
} else if (itemBottom > viewBottom - pad) {
listEl.scrollTop = itemBottom - listEl.clientHeight + pad;
}
};
const render = () => {
listEl.innerHTML = "";
if (!items || items.length === 0) {
close();
return;
}
listEl.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();
});
li.addEventListener("mouseenter", () => {
selectedIndex = index;
listEl.querySelectorAll(".mrmth-autocomplete-item").forEach((el, i) => {
el.classList.toggle("is-selected", i === index);
});
updateTooltip(item);
});
listEl.appendChild(li);
});
scrollSelectedIntoView();
if (items[selectedIndex]) {
updateTooltip(items[selectedIndex]);
} else {
tooltipEl.style.display = "none";
}
if (open) positionDropdown();
};
const positionDropdown = () => {
if (!prefixInfo || !layoutEl || !listEl) return; // Změna kontroly prvků
const cursorPos = textarea.selectionStart;
const caret = getCaretScreenPosition(textarea, cursorPos);
const textareaRect = textarea.getBoundingClientRect();
const scale = caret.scale ?? getTextareaViewportScale(textarea);
const lineHeight = getViewportLineHeight(textarea, scale);
const gap = 6;
const viewportPad = 8;
const preferredMax = 220;
const minListHeight = 48;
const lineTop = caret.top;
const lineBottom = lineTop + lineHeight;
const spaceBelow = window.innerHeight - lineBottom - gap - viewportPad;
const spaceAbove = lineTop - gap - viewportPad;
const spaceRight = window.innerWidth - textareaRect.right - gap - viewportPad;
let placement = "below";
if (spaceBelow < minListHeight) {
if (spaceRight >= 160) {
placement = "right";
} else if (spaceAbove > spaceBelow + 40) {
placement = "above";
}
}
let maxHeight;
let top;
let left;
const dropdownWidthTarget = Math.max(180, textareaRect.width);
if (placement === "below") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceBelow));
top = lineBottom + gap;
left = caret.left;
} else if (placement === "above") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceAbove));
top = lineTop - gap - maxHeight;
if (top < viewportPad) {
maxHeight = Math.max(minListHeight, lineTop - gap - viewportPad);
top = lineTop - gap - maxHeight;
}
left = caret.left;
} else {
maxHeight = Math.min(preferredMax, window.innerHeight - viewportPad * 2);
top = Math.max(viewportPad, Math.min(lineTop, window.innerHeight - maxHeight - viewportPad));
left = textareaRect.right + gap;
}
listEl.style.width = `260px`;
listEl.style.maxHeight = `${maxHeight}px`;
layoutEl.style.top = `${top}px`;
layoutEl.style.visibility = "hidden";
layoutEl.style.left = "0px";
const dropdownWidth = 260;
left = Math.max(viewportPad, Math.min(left, window.innerWidth - dropdownWidth - viewportPad));
layoutEl.style.left = `${left}px`;
layoutEl.style.visibility = "";
};
const applySelected = () => {
if (selectedIndex < 0 || selectedIndex >= items.length) return;
const item = items[selectedIndex];
const text = textarea.value;
const before = text.slice(0, prefixInfo.start);
const after = text.slice(textarea.selectionStart);
textarea.value = before + item.insertText + after;
textarea.selectionStart = textarea.selectionEnd = before.length + item.insertText.length;
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();
if (typeof maybeOpen === "function") {
maybeOpen(true);
} else {
const text = textarea.value;
const cursor = textarea.selectionStart;
const prefix = getWordPrefix(text, cursor);
if (prefix) {
const suggestions = buildSuggestions(prefix.word);
if (suggestions.length > 0) openWith(suggestions, prefix);
}
}
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 onReposition = () => {
if (open) positionDropdown();
};
textarea.addEventListener("input", typeof onInput === "function" ? onInput : () => {
const text = textarea.value;
const cursor = textarea.selectionStart;
const prefix = getWordPrefix(text, cursor);
if (!prefix) { close(); return; }
const suggestions = buildSuggestions(prefix.word);
if (suggestions.length > 0) openWith(suggestions, prefix);
else close();
});
textarea.addEventListener("keydown", onKeyDown);
textarea.addEventListener("blur", onBlur);
textarea.addEventListener("scroll", onReposition);
window.addEventListener("scroll", onReposition, true);
window.addEventListener("resize", onReposition);
return {
close,
destroy() {
close();
window.removeEventListener("scroll", onReposition, true);
window.removeEventListener("resize", onReposition);
if (layoutEl) {
layoutEl.remove();
}
textarea.removeEventListener("keydown", onKeyDown);
textarea.removeEventListener("blur", onBlur);
textarea.removeEventListener("scroll", onReposition);
if (typeof onInput === "function") {
textarea.removeEventListener("input", onInput);
}
},
};
}
export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };