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-layout { display: flex; flex-direction: row; align-items: flex-start; position: fixed; z-index: 10000; pointer-events: none; } .mrmth-autocomplete-layout[hidden], .mrmth-signature-hint[hidden] { display: none; } .mrmth-autocomplete { width: 260px; 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; } .mrmth-autocomplete-item.is-variable .mrmth-ac-name { color: #ecf0f1; } .mrmth-autocomplete-tooltip, .mrmth-signature-hint { max-width: 320px; padding: 8px 10px; 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; } .mrmth-autocomplete-tooltip { min-width: 200px; margin-left: 8px; pointer-events: auto; } .mrmth-signature-hint { position: fixed; z-index: 10000; pointer-events: none; } .mrmth-tt-title, .mrmth-sig-call { color: #c39bd3; font-weight: 600; } .mrmth-tt-param, .mrmth-sig-param { margin-top: 2px; color: #d5d9de; } .mrmth-sig-param { padding-bottom: 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); margin-bottom: 4px; } .mrmth-param-name { color: #ffd700; } .mrmth-tt-details { margin-top: 4px; white-space: pre-wrap; color: #9aa0a6; } .mrmth-sig-call .mrmth-sig-active { color: #ffd700; text-decoration: underline; } `; document.head.appendChild(style); } export function escapeHtml(value) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" }; const OPENING_BRACKETS = new Set(Object.keys(BRACKET_PAIRS)); const CLOSING_BRACKETS = new Set(Object.values(BRACKET_PAIRS)); const QUOTES = new Set(['"', "'"]); const INDENT = " "; // Mirrors the MathExpr.g4 lexer closely enough for highlighting. Unterminated // block comments and strings run to the end of the text/line so they show up. const TOKEN_PATTERN = /#.*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\.|[^"\\\r\n])*"?|'(?:\\.|[^'\\\r\n])*'?|\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\B\.\d+(?:[eE][+-]?\d+)?|==|!=|>=|<=|<<|>>|->|\+=|-=|\*=|\/=|%=|[+\-*/%^=<>|?:,;()\[\]{}]|[a-zA-Z_][a-zA-Z_0-9]*|\s+|./g; /** * Splits script text into tokens. Bracket tokens get `depth` (per bracket * type, for rainbow colouring) and `match` (index of the paired bracket token, * or -1 when unmatched). */ export function tokenize(text) { const tokens = []; const openStack = []; const depthByType = { "(": 0, "[": 0, "{": 0 }; TOKEN_PATTERN.lastIndex = 0; let m; while ((m = TOKEN_PATTERN.exec(text)) !== null) { const value = m[0]; const c = value[0]; const token = { type: "text", value, start: m.index, depth: 0, match: -1 }; if (c === "#" || value.startsWith("/*")) { token.type = "comment"; } else if (c === '"' || c === "'") { token.type = "string"; } else if (OPENING_BRACKETS.has(c)) { token.type = "bracket"; token.depth = depthByType[c]++; openStack.push(tokens.length); } else if (CLOSING_BRACKETS.has(c)) { token.type = "bracket"; const openIndex = openStack[openStack.length - 1]; const open = tokens[openIndex]; if (open && BRACKET_PAIRS[open.value] === c) { openStack.pop(); token.depth = --depthByType[open.value]; token.match = openIndex; open.match = tokens.length; } } else if (/^(?:[+\-*/%^=<>|?:,;]|!=)/.test(value)) { token.type = "operator"; } else if (/^\.?\d/.test(value)) { token.type = "number"; } else if (/^[a-zA-Z_]/.test(c)) { if (KEYWORDS.has(value)) token.type = "keyword"; else if (CONSTANTS.has(value)) token.type = "constant"; else if (FUNCTIONS.has(value)) token.type = "function"; else token.type = "variable"; } tokens.push(token); } return tokens; } function isBlank(token) { return token.type === "text" && !token.value.trim(); } /** * Insert text through the browser's editing pipeline so it lands on the native * undo stack (plain `textarea.value = ...` assignments wipe Ctrl+Z history). * An empty `text` deletes the range. */ export function insertText(textarea, start, end, text) { textarea.focus(); textarea.setSelectionRange(start, end); const ok = text ? document.execCommand("insertText", false, text) : document.execCommand("delete"); if (!ok) { textarea.setRangeText(text, start, end, "end"); textarea.dispatchEvent(new Event("input", { bubbles: true })); } } export function renderHighlight(text, cursor) { const tokens = tokenize(text); // Bracket next to the cursor (left side preferred) and its partner. const active = new Set(); const isPairedAt = (pos) => (t) => t.type === "bracket" && t.start === pos && t.match !== -1; let nearIndex = tokens.findIndex(isPairedAt(cursor - 1)); if (nearIndex === -1) nearIndex = tokens.findIndex(isPairedAt(cursor)); if (nearIndex !== -1) { active.add(nearIndex); active.add(tokens[nearIndex].match); } return tokens.map((t, i) => { if (t.type === "text") return escapeHtml(t.value); if (t.type === "bracket") { let cls = t.match === -1 ? "mrmth-bracket-unmatched" : `mrmth-bracket-${t.depth % 6}`; if (active.has(i)) cls += " mrmth-bracket-active"; return `${escapeHtml(t.value)}`; } return `${escapeHtml(t.value)}`; }).join(""); } /** * Editor key behaviour: bracket/quote pairing, Backspace pair delete, auto * indent on Enter, Tab / Shift+Tab indent. Returns true when the key was * handled and the caller should preventDefault. Keys already consumed by the * autocomplete popup arrive with `defaultPrevented` and are ignored. */ export function handleEditingKey(textarea, e) { if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false; const { selectionStart: start, selectionEnd: end, value } = textarea; const prev = value[start - 1] ?? ""; const next = value[end] ?? ""; const lineStart = value.lastIndexOf("\n", start - 1) + 1; const key = e.key; // Type over an already present closing bracket/quote. if (start === end && next === key && (CLOSING_BRACKETS.has(key) || QUOTES.has(key))) { textarea.setSelectionRange(start + 1, start + 1); return true; } if (Object.hasOwn(BRACKET_PAIRS, key) || QUOTES.has(key)) { const closing = BRACKET_PAIRS[key] ?? key; if (start !== end) { insertText(textarea, start, end, key + value.slice(start, end) + closing); textarea.setSelectionRange(start + 1, end + 1); return true; } if (/\w/.test(next) || (QUOTES.has(key) && /\w/.test(prev))) return false; insertText(textarea, start, end, key + closing); textarea.setSelectionRange(start + 1, start + 1); return true; } // `}` typed on a whitespace-only line closes one indent level. if (key === "}" && start === end && /^[ \t]*$/.test(value.slice(lineStart, start)) && value.slice(lineStart, start).endsWith(INDENT)) { insertText(textarea, start - INDENT.length, start, "}"); return true; } if (key === "Backspace" && start === end && start > 0 && (BRACKET_PAIRS[prev] === next || (QUOTES.has(prev) && prev === next))) { insertText(textarea, start - 1, start + 1, ""); return true; } if (key === "Enter" && !e.shiftKey) { const before = value.slice(lineStart, start); const lead = before.match(/^[ \t]*/)[0]; if (before.trimEnd().endsWith("{")) { const inner = lead + INDENT; const text = next === "}" ? `\n${inner}\n${lead}` : `\n${inner}`; insertText(textarea, start, end, text); const caret = start + 1 + inner.length; textarea.setSelectionRange(caret, caret); } else { insertText(textarea, start, end, `\n${lead}`); } return true; } if (key === "Tab") { if (!e.shiftKey && start === end) { insertText(textarea, start, end, INDENT); return true; } // Indent / unindent every line touched by the selection. const blockEnd = end > start && value[end - 1] === "\n" ? end - 1 : end; const block = value.slice(lineStart, blockEnd); const lines = block.split("\n"); const newLines = e.shiftKey ? lines.map((l) => l.replace(/^(?: {1,4}|\t)/, "")) : lines.map((l) => INDENT + l); const newBlock = newLines.join("\n"); if (newBlock === block) return true; insertText(textarea, lineStart, blockEnd, newBlock); const firstDelta = newLines[0].length - lines[0].length; const totalDelta = newBlock.length - block.length; textarea.setSelectionRange(Math.max(lineStart, start + firstDelta), Math.max(lineStart, end + totalDelta)); return true; } return false; } 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 || ch === "\n") inString = false; continue; } if (ch === "#") { inLineComment = true; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } if (ch === '"' || ch === "'") { inString = true; stringChar = ch; } } return inString || inLineComment || 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)`; } // Names defined or used in the script itself; `name(` counts as a function. function collectUserSymbols(text, skipStart) { const tokens = tokenize(text); const symbols = new Map(); tokens.forEach((t, i) => { if (t.type !== "variable" || t.start === skipStart) return; let j = i + 1; while (tokens[j] && isBlank(tokens[j])) j++; if (tokens[j]?.value === "(") symbols.set(t.value, "function"); else if (!symbols.has(t.value)) symbols.set(t.value, "variable"); }); return symbols; } const KIND_ORDER = { variable: 0, function: 1, constant: 2, keyword: 3 }; function buildSuggestions(prefix, userSymbols = new Map()) { const lower = prefix.toLowerCase(); const items = []; const matches = (name) => name.toLowerCase().startsWith(lower); for (const [name, kind] of userSymbols) { if (matches(name)) items.push({ name, kind, detail: kind === "function" ? "user" : "" }); } for (const name of FUNCTIONS) { if (matches(name)) items.push({ name, kind: "function", detail: formatArgHint(FUNCTION_META[name]) }); } for (const name of CONSTANTS) { if (matches(name)) items.push({ name, kind: "constant", detail: "" }); } for (const name of KEYWORDS) { if (matches(name)) items.push({ name, kind: "keyword", detail: "" }); } items.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)); return items.slice(0, 40); } // Innermost built-in call whose argument list contains the cursor. function findCallContext(text, cursor) { const tokens = tokenize(text.slice(0, cursor)); let depth = 0; let argIndex = 0; for (let i = tokens.length - 1; i >= 0; i--) { const { type, value } = tokens[i]; if (value === "," && type === "operator") { if (depth === 0) argIndex++; continue; } if (type !== "bracket") continue; if (CLOSING_BRACKETS.has(value)) { depth++; continue; } if (depth > 0) { depth--; continue; } // Inside a list or dict literal: it is one argument of the enclosing call. if (value !== "(") { argIndex = 0; continue; } let j = i - 1; while (j >= 0 && isBlank(tokens[j])) j--; const name = tokens[j]?.type === "function" ? tokens[j].value : null; return name ? { name, argIndex, argCount: countCallArgs(text, tokens[i].start) } : null; } return null; } // Number of arguments in the call whose "(" is at openPos, including those after the cursor. function countCallArgs(text, openPos) { let depth = 0; let commas = 0; let hasArg = false; for (const t of tokenize(text.slice(openPos))) { if (t.type === "bracket") { if (OPENING_BRACKETS.has(t.value)) { if (++depth === 1) continue; } else if (--depth === 0) { break; } } if (depth === 1 && t.type === "operator" && t.value === ",") commas++; else if (!isBlank(t) && t.type !== "comment") hasArg = true; } return hasArg || commas ? commas + 1 : 0; } // Arity of one documented call form, e.g. ["x", "[dims]"] or ["r", "[g", "b]"]. function formArity(args) { let depth = 0; let min = 0; let max = 0; for (const a of args) { const optional = depth > 0 || a.startsWith("["); depth += (a.match(/\[/g) ?? []).length - (a.match(/\]/g) ?? []).length; if (a.includes("...")) return { min, max: Infinity }; max++; if (!optional) min++; } return { min, max }; } // First documented form that accepts argCount arguments (e.g. rgb_to_hsv(rgb) vs rgb_to_hsv(r, g, b)). function pickForm(forms, argCount) { return forms.find((form) => { const { min, max } = formArity(form); return argCount >= min && argCount <= max; }) ?? forms[0]; } 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 span = document.createElement("span"); span.textContent = textarea.value.substring(0, position); div.appendChild(span); const marker = document.createElement("span"); marker.textContent = "​"; div.appendChild(marker); document.body.appendChild(div); const top = marker.offsetTop; const left = marker.offsetLeft; div.remove(); return { top, left }; } // The node canvas may scale the textarea with a CSS transform. function getTextareaViewportScale(textarea) { const layoutHeight = textarea.offsetHeight; if (layoutHeight <= 0) return 1; return textarea.getBoundingClientRect().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, }; } export const SIGNATURE_HINTS_SETTING = "mrmth.ScriptEditor.SignatureHints"; export const PARAMETER_DOCS_SETTING = "mrmth.ScriptEditor.ParameterDescriptions"; export const EDITOR_SETTINGS = [ { id: SIGNATURE_HINTS_SETTING, category: ["More math", "Script editor", "Signature hints"], name: "Show signature hints", tooltip: "Show the function signature and description while typing its arguments.", type: "boolean", defaultValue: true, }, { id: PARAMETER_DOCS_SETTING, category: ["More math", "Script editor", "Parameter descriptions"], name: "Show parameter descriptions", tooltip: "Describe the argument being typed in the signature hint, and list the arguments in the completion tooltip.", type: "boolean", defaultValue: true, }, ]; /** * Attach identifier autocomplete and built-in signature hints to a script * textarea. The popups are portaled to document.body. Must be attached before * other keydown handlers so they can skip keys it consumed (`defaultPrevented`). * @param {HTMLTextAreaElement} textarea * @param {(id: string) => unknown} [getSetting] - reads one of EDITOR_SETTINGS; features stay on unless it returns false * @returns {{ destroy: () => void, close: () => void }} */ export function attachAutocomplete(textarea, getSetting = () => undefined) { ensureAutocompleteStyles(); const enabled = (id) => getSetting(id) !== false; const layoutEl = document.createElement("div"); layoutEl.className = "mrmth-autocomplete-layout"; layoutEl.hidden = true; const listEl = document.createElement("ul"); listEl.className = "mrmth-autocomplete"; layoutEl.appendChild(listEl); const tooltipEl = document.createElement("div"); tooltipEl.className = "mrmth-autocomplete-tooltip"; tooltipEl.style.display = "none"; layoutEl.appendChild(tooltipEl); const hintEl = document.createElement("div"); hintEl.className = "mrmth-signature-hint"; hintEl.hidden = true; document.body.appendChild(layoutEl); document.body.appendChild(hintEl); let items = []; let selectedIndex = 0; let navigated = false; let prefixInfo = null; let open = false; listEl.addEventListener("wheel", (e) => { e.stopPropagation(); const maxScroll = Math.max(0, listEl.scrollHeight - listEl.clientHeight); if ((e.deltaY < 0 && listEl.scrollTop <= 0) || (e.deltaY > 0 && listEl.scrollTop >= maxScroll)) { e.preventDefault(); } }, { passive: false }); function updateTooltip(item) { const meta = item.kind === "function" ? FUNCTION_META[item.name] : null; if (!meta?.description) { tooltipEl.style.display = "none"; return; } let argsText = `Arguments: min ${meta.minArgs}`; argsText += meta.maxArgs === null ? "+ (variadic)" : `, max ${meta.maxArgs}`; const params = enabled(PARAMETER_DOCS_SETTING) ? Object.entries(meta.params) : []; tooltipEl.innerHTML = `
${escapeHtml(meta.signature)}
${escapeHtml(meta.description)}
${meta.details ? `
${escapeHtml(meta.details)}
` : ""} ${params.map(([name, desc]) => `
${escapeHtml(name)}: ${escapeHtml(desc)}
`).join("")}
${argsText}
${meta.aliases.length ? `
Also: ${escapeHtml(meta.aliases.join(", "))}
` : ""} `; tooltipEl.style.display = "block"; const rect = tooltipEl.getBoundingClientRect(); const flip = rect.right > window.innerWidth; layoutEl.style.flexDirection = flip ? "row-reverse" : "row"; tooltipEl.style.marginLeft = flip ? "0" : "8px"; tooltipEl.style.marginRight = flip ? "8px" : "0"; } const close = () => { open = false; layoutEl.hidden = true; listEl.innerHTML = ""; tooltipEl.style.display = "none"; items = []; selectedIndex = 0; prefixInfo = null; }; const openWith = (nextItems, nextPrefix) => { items = nextItems; selectedIndex = 0; navigated = false; prefixInfo = nextPrefix; open = true; layoutEl.hidden = false; hideHint(); render(); }; const scrollSelectedIntoView = () => { const selected = listEl.children[selectedIndex]; if (!selected) return; const itemTop = selected.offsetTop; const itemBottom = itemTop + selected.offsetHeight; const pad = 2; if (itemTop < listEl.scrollTop + pad) { listEl.scrollTop = Math.max(0, itemTop - pad); } else if (itemBottom > listEl.scrollTop + listEl.clientHeight - pad) { listEl.scrollTop = itemBottom - listEl.clientHeight + pad; } }; const render = () => { listEl.innerHTML = ""; items.forEach((item, index) => { const li = document.createElement("li"); li.className = `mrmth-autocomplete-item is-${item.kind}${index === selectedIndex ? " is-selected" : ""}`; const name = document.createElement("span"); name.className = "mrmth-ac-name"; name.textContent = item.name; const detail = document.createElement("span"); detail.className = "mrmth-ac-hint"; detail.textContent = item.detail; const kind = document.createElement("span"); kind.className = "mrmth-ac-kind"; kind.textContent = item.kind; li.append(name, detail, kind); li.addEventListener("mousedown", (e) => { e.preventDefault(); selectedIndex = index; applySelected(); }); li.addEventListener("mouseenter", () => { selectedIndex = index; for (const el of listEl.children) el.classList.toggle("is-selected", el === li); updateTooltip(item); }); listEl.appendChild(li); }); scrollSelectedIntoView(); updateTooltip(items[selectedIndex]); positionDropdown(); }; const positionDropdown = () => { const caret = getCaretScreenPosition(textarea, textarea.selectionStart); const textareaRect = textarea.getBoundingClientRect(); const lineHeight = getViewportLineHeight(textarea, caret.scale); const gap = 6; const viewportPad = 8; const preferredMax = 220; const minListHeight = 48; const dropdownWidth = 260; 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 = caret.left; if (placement === "below") { maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceBelow)); top = lineBottom + gap; } 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; } } 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.maxHeight = `${maxHeight}px`; layoutEl.style.top = `${top}px`; layoutEl.style.left = `${Math.max(viewportPad, Math.min(left, window.innerWidth - dropdownWidth - viewportPad))}px`; }; const applySelected = () => { const item = items[selectedIndex]; if (!item) return; const end = textarea.selectionStart; const isCall = item.kind === "function"; const hasParen = textarea.value[end] === "("; let caret = prefixInfo.start + item.name.length; if (isCall && hasParen) { insertText(textarea, prefixInfo.start, end, item.name); caret += 1; } else if (isCall) { insertText(textarea, prefixInfo.start, end, `${item.name}()`); caret += 1; } else { insertText(textarea, prefixInfo.start, end, item.name); } textarea.setSelectionRange(caret, caret); close(); updateHint(); }; const maybeOpen = (force) => { const text = textarea.value; const cursor = textarea.selectionStart; if (cursor !== textarea.selectionEnd || isCursorInStringOrComment(text, cursor)) { close(); return; } const info = getWordPrefix(text, cursor); if (!info || (!force && !open && info.word.length < 2)) { close(); return; } const suggestions = buildSuggestions(info.word, collectUserSymbols(text, info.start)); // Nothing to offer once the word is already complete. if (!suggestions.length || (!force && suggestions.length === 1 && suggestions[0].name === info.word)) { close(); return; } openWith(suggestions, info); }; function hideHint() { hintEl.hidden = true; } function updateHint() { const cursor = textarea.selectionStart; if (open || !enabled(SIGNATURE_HINTS_SETTING) || document.activeElement !== textarea || cursor !== textarea.selectionEnd) { hideHint(); return; } const ctx = findCallContext(textarea.value, cursor); const meta = ctx && FUNCTION_META[ctx.name]; if (!meta) { hideHint(); return; } const form = pickForm(meta.forms, Math.max(ctx.argCount, ctx.argIndex + 1)); // A variadic arg (`...`) absorbs every argument from its position on. const variadicAt = form.findIndex((a) => a.includes("...")); let active = ctx.argIndex; if (variadicAt !== -1 && active >= variadicAt) active = variadicAt; const args = form .map((a, i) => (i === active ? `${escapeHtml(a)}` : escapeHtml(a))) .join(", "); // Doc names drop the optional/variadic markup: "[dims]" and "[g" -> "dims", "g". const activeName = form[active]?.replace(/[[\].]/g, "").trim(); const paramDesc = enabled(PARAMETER_DOCS_SETTING) ? meta.params[activeName] : undefined; hintEl.innerHTML = `
${escapeHtml(ctx.name)}(${args})
${paramDesc ? `
${escapeHtml(activeName)}: ${escapeHtml(paramDesc)}
` : ""} ${meta.description ? `
${escapeHtml(meta.description)}
` : ""} `; hintEl.hidden = false; const caret = getCaretScreenPosition(textarea, cursor); const lineHeight = getViewportLineHeight(textarea, caret.scale); const hintRect = hintEl.getBoundingClientRect(); const viewportPad = 8; let top = caret.top - hintRect.height - 4; if (top < viewportPad) top = caret.top + lineHeight + 6; hintEl.style.top = `${top}px`; hintEl.style.left = `${Math.max(viewportPad, Math.min(caret.left, window.innerWidth - hintRect.width - viewportPad))}px`; } const onInput = () => { maybeOpen(false); updateHint(); }; const onKeyDown = (e) => { if (e.ctrlKey && e.key === " ") { e.preventDefault(); maybeOpen(true); return; } if (!open) { if (e.key === "Escape") hideHint(); return; } if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const step = e.key === "ArrowDown" ? 1 : -1; selectedIndex = (selectedIndex + step + items.length) % items.length; navigated = true; render(); return; } // Enter only accepts after the list was navigated, otherwise it would // swallow newlines after words like `return` that happen to match. if (e.key === "Tab" || (e.key === "Enter" && navigated)) { e.preventDefault(); applySelected(); return; } if (e.key === "Enter" || e.key === "Escape") { if (e.key === "Escape") e.preventDefault(); close(); } }; const onSelectionChange = () => { if (document.activeElement === textarea) updateHint(); }; const onBlur = () => { setTimeout(close, 120); hideHint(); }; const onReposition = () => { if (open) positionDropdown(); if (!hintEl.hidden) updateHint(); }; textarea.addEventListener("input", onInput); textarea.addEventListener("keydown", onKeyDown); textarea.addEventListener("blur", onBlur); textarea.addEventListener("scroll", onReposition); document.addEventListener("selectionchange", onSelectionChange); window.addEventListener("scroll", onReposition, true); window.addEventListener("resize", onReposition); return { close, destroy() { close(); textarea.removeEventListener("input", onInput); textarea.removeEventListener("keydown", onKeyDown); textarea.removeEventListener("blur", onBlur); textarea.removeEventListener("scroll", onReposition); document.removeEventListener("selectionchange", onSelectionChange); window.removeEventListener("scroll", onReposition, true); window.removeEventListener("resize", onReposition); layoutEl.remove(); hintEl.remove(); }, }; } export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };