From 0b116dfed9401bc13fddfe75013467639dc83f25 Mon Sep 17 00:00:00 2001 From: mcDandy Date: Wed, 22 Apr 2026 13:46:49 +0200 Subject: [PATCH] AI: rewrite of ScriptInputNode --- web/script_text_input.V1.js.m | 510 +++++++++++++++++++++ web/script_text_input.V2.js.m | 187 ++++++++ web/script_text_input.js | 839 +++++++++++++++------------------- 3 files changed, 1074 insertions(+), 462 deletions(-) create mode 100644 web/script_text_input.V1.js.m create mode 100644 web/script_text_input.V2.js.m diff --git a/web/script_text_input.V1.js.m b/web/script_text_input.V1.js.m new file mode 100644 index 0000000..2067842 --- /dev/null +++ b/web/script_text_input.V1.js.m @@ -0,0 +1,510 @@ +import { app } from "/scripts/app.js"; + +const NODE_IDS = new Set(["mrmth_ScriptInput"]); +const NODE_NAMES = new Set(["ScriptTextInput"]); +const STYLE_ID = "mrmth-script-line-numbers"; + +const KEYWORDS = new Set(["if", "else", "while", "for", "in", "break", "continue", "return", "none", "None", "null", "NULL"]); +const CONSTANTS = new Set(["pi", "PI", "e", "E"]); +const FUNCTIONS = new Set([ + "sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", + "abs", "sqrt", "ln", "log", "exp", "smin", "smax", "tmin", "tmax", "tnorm", "snorm", "floor", "ceil", + "round", "gamma", "pow", "sigm", "clamp", "fft", "ifft", "angle", "print", "print_shape", "pshp", "nvl", + "nan_to_num", "lerp", "step", "smoothstep", "fract", "relu", "softplus", "gelu", "sign", "map", "ezconvolution", + "ezconv", "convolution", "conv", "swap", "permute", "perm", "reshape", "rshp", "range", "topk", "botk", "pinv", + "sum", "mean", "std", "var", "quartile", "quartil", "percentile", "prcnt", "quantile", "dot", "moment","erf","erfinv", "any", + "all", "edge", "blur", "gaussian", "median", "mode", "cumsum", "cumprod", "topk_ind", "topk_indices", "botk_ind", + "botk_indices", "cubic_ease", "cubic", "elastic_ease", "elastic", "sine_ease", "sine", "smootherstep", "dist", + "distance", "remap", "cossim", "cosine_similarity", "count", "cnt", "length", "flatten", "append", "get_value", + "flow_apply", "batch_shuffle", "shuffle", "motion_mask", "flow_to_image", "overlay", "pad", "cross", "matmul", "rife", + "bnot", "bitwise_not", "bitcount", "popcount", "popcnt", "shape", "band", "bitwise_and", "bxor", "bitwise_xor", + "bor", "bitwise_or", "tensor", "stack_push", "stack_pop", "stack_clear", "stack_has", "stack_get", "timestamp","now", + "sort", "argsort", "argmin", "argmax", "softmax", "softmin", "unique", "flip", "cov", "corr", "correlation", "entropy", + "crop", "cat", "concatenate", "concat", "float","int", "linspace", "logspace", "roll","select", + "noise", "randn", "random_normal", "rand", "randu", "random_uniform", "randc", "random_cauchy", "rande", + "random_exponential", "randln", "random_log_normal", "randb", "random_bernoulli", "randp", "random_poisson", "randg", + "random_gamma", "randbeta", "random_beta", "randl", "random_laplace", "randgumbel", "random_gumbel", "randw", + "random_weibull", "randchi2", "random_chi2", "randt", "random_studentt", "perlin", "perlin_noise", "cellular", "voronoi", + "worley", "cellular_noise", "voronoi_noise", "plasma", "turbulence", "plasma_noise", + "upper", "lower", "split", "join", "substring", "substr", "find", "trim", "replace", + "dilate", "erode", "morph_open", "morph_close", + "rgb_to_hsv", "hsv_to_rgb", "int_to_rgb", "rgb_to_int", "where", "histogram", "hist", "flow_mag", "flow_magnitude", "flow_ang", "flow_angle", +]); + +const BRACKET_PAIRS = { + '(': ')', + '[': ']', + '{': '}' +}; +const OPENING_BRACKETS = new Set(['(', '[', '{']); +const CLOSING_BRACKETS = new Set([')', ']', '}']); + +function escapeHtml(value) { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return ch; + } + }); +} + +function tokenize(text) { + const tokens = []; + const pattern = /#.*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?\b|\B\.\d+(?:[eE][+-]?\d+)?\b|==|!=|>=|<=|<<|>>|->|\+=|-=|\*=|\/=|%=|[+\-*/%^=<>|?:,;()\[\]{}]|\b[a-zA-Z_][a-zA-Z_0-9]*\b|\s+|./g; + let match; + const bracketStack = []; + const depthByType = { '(': 0, '[': 0, '{': 0 }; + + while ((match = pattern.exec(text)) !== null) { + const value = match[0]; + let type = "text"; + let depth = 0; + + if (value.startsWith("#") || value.startsWith("/*")) { + type = "comment"; + } else if (value.startsWith('"') || value.startsWith("'")) { + type = "string"; + } else if (OPENING_BRACKETS.has(value)) { + depth = depthByType[value]; + depthByType[value]++; + bracketStack.push(value); + type = "bracket"; + } else if (CLOSING_BRACKETS.has(value)) { + if (bracketStack.length > 0) { + const lastOpen = bracketStack[bracketStack.length - 1]; + if (BRACKET_PAIRS[lastOpen] === value) { + // Use depth of bracket type being closed, before decrement + depth = depthByType[lastOpen] - 1; + depthByType[lastOpen]--; + bracketStack.pop(); + } else { + // Mismatched closing bracket + depth = 0; + } + } else { + // Unmatched closing bracket + depth = 0; + } + type = "bracket"; + } else if (/^[+\-*/%^=<>|?:,;]|==|!=|>=|<=|<<|>>|->$/.test(value)) { + type = "operator"; + } else if (/^(?:\d+\.\d*|\d+|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) { + type = "number"; + } else if (/^[a-zA-Z_]/.test(value)) { + if (KEYWORDS.has(value)) { + type = "keyword"; + } else if (CONSTANTS.has(value)) { + type = "constant"; + } else if (FUNCTIONS.has(value)) { + type = "function"; + } else { + type = "variable"; + } + } + tokens.push({ type, value, depth }); + } + return tokens; +} + +function renderHighlight(text) { + const tokens = tokenize(text); + return tokens + .map((token) => { + if (token.type === "text") { + return escapeHtml(token.value); + } + if (token.type === "bracket") { + const depthClass = token.depth % 6; + return `${escapeHtml(token.value)}`; + } + return `${escapeHtml(token.value)}`; + }) + .join(""); +} + +function hasScriptInput(nodeData) { + const input = nodeData?.input || {}; + const required = input.required || {}; + const optional = input.optional || {}; + return Object.prototype.hasOwnProperty.call(required, "script") || Object.prototype.hasOwnProperty.call(optional, "script"); +} + +function isTargetNode(nodeData) { + if (!nodeData) { + return false; + } + if (NODE_IDS.has(nodeData.node_id)) { + return true; + } + if (NODE_IDS.has(nodeData.name)) { + return true; + } + if (NODE_NAMES.has(nodeData.name)) { + return true; + } + if (NODE_NAMES.has(nodeData.display_name)) { + return true; + } + return hasScriptInput(nodeData); +} + +function ensureStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .mrmth-line-editor { + display: flex; + align-items: stretch; + width: 100%; + height: 100%; + min-height: 100%; + gap: 0; + overflow: hidden; + } + .mrmth-line-numbers { + padding: 6px 8px; + text-align: right; + user-select: none; + color: #9aa0a6; + background: rgba(0, 0, 0, 0.4); + border-right: 1px solid rgba(255, 255, 255, 0.1); + font-family: monospace; + font-size: 12px; + line-height: 1.4; + white-space: pre; + overflow: hidden; + box-sizing: border-box; + } + .mrmth-line-numbers-content { + white-space: pre; + } + .mrmth-editor-container { + position: relative; + flex: 1; + min-height: 30px; + overflow: hidden; + } + .mrmth-line-input { + position: relative; + z-index: 2; + flex: 1; + width: 100%; + height: 100%; + box-sizing: border-box; + font-family: monospace; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + resize: none; + background: transparent; + color: transparent; + } + .mrmth-syntax-layer { + position: absolute; + inset: 0; + margin: 0; + z-index: 1; + pointer-events: none; + white-space: pre-wrap; + word-wrap: break-word; + overflow: hidden; + mix-blend-mode: normal; + opacity: 1; + } + .mrmth-token-comment { color: #7f8c8d; } + .mrmth-token-number { color: #f39c12; } + .mrmth-token-constant { color: #f1c40f; } + .mrmth-token-keyword { color: #e74c3c; } + .mrmth-token-function { color: #8e44ad; } + .mrmth-token-variable { color: #ecf0f1; } + .mrmth-token-operator { color: #95a5a6; } + .mrmth-token-string { color: #4ea658; } + .mrmth-token-bracket { font-weight: bold; } + .mrmth-bracket-0 { color: #ffd700; } + .mrmth-bracket-1 { color: #da70d6; } + .mrmth-bracket-2 { color: #87cefa; } + .mrmth-bracket-3 { color: #98fb98; } + .mrmth-bracket-4 { color: #ff6347; } + .mrmth-bracket-5 { color: #ffa500; } + `; + document.head.appendChild(style); +} + +function measureLineHeight(inputEl) { + const probe = document.createElement("span"); + const style = getComputedStyle(inputEl); + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + probe.style.whiteSpace = "pre"; + probe.style.fontFamily = style.fontFamily; + probe.style.fontSize = style.fontSize; + probe.style.fontWeight = style.fontWeight; + probe.style.fontStyle = style.fontStyle; + probe.style.lineHeight = style.lineHeight; + probe.textContent = "A"; + document.body.appendChild(probe); + const height = probe.getBoundingClientRect().height; + document.body.removeChild(probe); + return height; +} + +function attachLineNumbers(widget) { + const inputEl = widget?.inputEl; + if (!inputEl || inputEl.dataset.mrmthLineNumbers) { + return false; + } + + const parent = inputEl.parentElement; + if (!parent) { + return false; + } + + ensureStyles(); + + const wrapper = document.createElement("div"); + wrapper.className = "mrmth-line-editor"; + + const gutter = document.createElement("div"); + gutter.className = "mrmth-line-numbers"; + + const gutterContent = document.createElement("div"); + gutterContent.className = "mrmth-line-numbers-content"; + + const editorContainer = document.createElement("div"); + editorContainer.className = "mrmth-editor-container"; + + const syntaxLayer = document.createElement("pre"); + syntaxLayer.className = "mrmth-syntax-layer"; + + parent.replaceChild(wrapper, inputEl); + wrapper.appendChild(gutter); + gutter.appendChild(gutterContent); + wrapper.appendChild(editorContainer); + editorContainer.appendChild(syntaxLayer); + editorContainer.appendChild(inputEl); + + wrapper.style.height = "100%"; + parent.style.height = "100%"; + + inputEl.classList.add("mrmth-line-input"); + inputEl.dataset.mrmthLineNumbers = "true"; + + const inputStyle = getComputedStyle(inputEl); + const lineHeightPx = measureLineHeight(inputEl); + + const applyAlpha = (color, alpha) => { + const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); + if (!rgbaMatch) { + return `rgba(0, 0, 0, ${alpha})`; + } + const r = Number(rgbaMatch[1]); + const g = Number(rgbaMatch[2]); + const b = Number(rgbaMatch[3]); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + }; + + const baseBackground = inputStyle.backgroundColor || "rgba(0, 0, 0, 0.4)"; + const overlayBackground = applyAlpha(baseBackground, 0.4); + + syntaxLayer.style.height = "100%"; + syntaxLayer.style.background = "transparent"; + inputEl.style.height = "100%"; + inputEl.style.background = overlayBackground; + + gutter.style.fontFamily = inputStyle.fontFamily; + gutter.style.fontSize = inputStyle.fontSize; + gutter.style.lineHeight = inputStyle.lineHeight; + gutter.style.paddingTop = inputStyle.paddingTop; + gutter.style.paddingBottom = inputStyle.paddingBottom; + + syntaxLayer.style.fontFamily = inputStyle.fontFamily; + syntaxLayer.style.fontSize = inputStyle.fontSize; + syntaxLayer.style.lineHeight = inputStyle.lineHeight; + syntaxLayer.style.padding = `${inputStyle.paddingTop} ${inputStyle.paddingRight} ${inputStyle.paddingBottom} ${inputStyle.paddingLeft}`; + syntaxLayer.style.width = "100%"; + syntaxLayer.style.boxSizing = "border-box"; + syntaxLayer.style.overflowY = "hidden"; + + inputEl.style.background = overlayBackground; + inputEl.style.color = "transparent"; + inputEl.style.caretColor = "#ffffff"; + inputEl.style.mixBlendMode = "lighten"; + + const updateNumbers = () => { + const rawLines = inputEl.value.split("\n"); + const textLineCount = Math.max(1, rawLines.length); + + const gutterLines = []; + + // Sync syntaxLayer width to match inputEl's actual content width + const scrollbarWidth = inputEl.offsetWidth - inputEl.clientWidth; + syntaxLayer.style.width = `calc(100% - ${scrollbarWidth}px)`; + + // Create a temporary clone + const measureClone = syntaxLayer.cloneNode(false); + measureClone.style.position = "absolute"; + measureClone.style.visibility = "hidden"; + measureClone.style.width = syntaxLayer.style.width; + measureClone.style.height = "auto"; + syntaxLayer.parentElement.appendChild(measureClone); + + // Build content with simple marker at start of each line + let html = ""; + for (let i = 0; i < textLineCount; i++) { + html += ``; + html += renderHighlight(rawLines[i] || " "); + if (i < textLineCount - 1) { + html += "\n"; + } + } + measureClone.innerHTML = html; + + // Simply read offsetTop of each marker + const totalHeight = measureClone.offsetHeight; + + for (let i = 0; i < textLineCount; i++) { + const marker = measureClone.querySelector(`[data-ln="${i}"]`); + const nextMarker = i < textLineCount - 1 ? measureClone.querySelector(`[data-ln="${i + 1}"]`) : null; + + const currentY = marker ? marker.offsetTop : i * lineHeightPx; + const nextY = nextMarker ? nextMarker.offsetTop : totalHeight; + + const heightDiff = nextY - currentY; + const visualLines = Math.max(1, Math.round(heightDiff / lineHeightPx)); + + gutterLines.push(String(i + 1)); + + for (let j = 1; j < visualLines; j++) { + gutterLines.push(" "); + } + } + + measureClone.remove(); + + gutterContent.textContent = gutterLines.join("\n"); + + gutter.style.height = `${inputEl.clientHeight}px`; + gutterContent.style.transform = `translateY(${-inputEl.scrollTop}px)`; + }; + + const updateHighlight = () => { + const sourceText = inputEl.value.endsWith("\n") ? `${inputEl.value} ` : inputEl.value; + const highlighted = renderHighlight(sourceText); + syntaxLayer.innerHTML = highlighted; + syntaxLayer.scrollTop = inputEl.scrollTop; + syntaxLayer.scrollLeft = inputEl.scrollLeft; + }; + + const handleBracketAutoClose = (e) => { + const key = e.key; + if (!OPENING_BRACKETS.has(key)) { + return; + } + + const selectionStart = inputEl.selectionStart; + const selectionEnd = inputEl.selectionEnd; + + if (selectionStart !== selectionEnd) { + return; + } + + const text = inputEl.value; + const charAfter = text[selectionStart] || ''; + + // Only prevent auto-close if the MATCHING closing bracket is after cursor + const closingBracket = BRACKET_PAIRS[key]; + const isMatchingClosingBracket = charAfter === closingBracket; + + // Prevent auto-close if next char is alphanumeric or a matching closing bracket + if (/[a-zA-Z0-9_]/.test(charAfter) || isMatchingClosingBracket) { + return; + } + + e.preventDefault(); + + const before = text.substring(0, selectionStart); + const after = text.substring(selectionStart); + inputEl.value = before + key + closingBracket + after; + inputEl.selectionStart = inputEl.selectionEnd = selectionStart + 1; + + updateNumbers(); + updateHighlight(); + + const event = new Event('input', { bubbles: true }); + inputEl.dispatchEvent(event); + }; + + inputEl.addEventListener("keydown", handleBracketAutoClose); + + inputEl.addEventListener("input", () => { + updateNumbers(); + updateHighlight(); + }); + inputEl.addEventListener("scroll", () => { + gutterContent.style.transform = `translateY(${-inputEl.scrollTop}px)`; + syntaxLayer.scrollTop = inputEl.scrollTop; + syntaxLayer.scrollLeft = inputEl.scrollLeft; + }); + + if (window.ResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + updateNumbers(); + updateHighlight(); + }); + resizeObserver.observe(inputEl); + } + + requestAnimationFrame(() => { + updateNumbers(); + updateHighlight(); + }); + + return true; +} + +function attachLineNumbersWithRetry(node) { + const widget = node.widgets?.find((w) => w.name === "script"); + if (attachLineNumbers(widget)) { + return; + } + + requestAnimationFrame(() => { + const retryWidget = node.widgets?.find((w) => w.name === "script"); + attachLineNumbers(retryWidget); + }); +} + +app.registerExtension({ + name: "mrmth.ScriptTextInput.LineNumbers", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (!isTargetNode(nodeData)) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + attachLineNumbersWithRetry(this); + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + onConfigure?.apply(this, arguments); + attachLineNumbersWithRetry(this); + }; + }, +}); diff --git a/web/script_text_input.V2.js.m b/web/script_text_input.V2.js.m new file mode 100644 index 0000000..dfc507f --- /dev/null +++ b/web/script_text_input.V2.js.m @@ -0,0 +1,187 @@ +import { app } from "/scripts/app.js"; + +const STYLE_ID = "mrmth-script-line-numbers-v2"; + +const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" }; +const OPENING_BRACKETS = new Set(["(", "[", "{"]); + +function ensureStyles() { + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .mrmth-v2-editor { + display: flex; + width: 100%; + min-height: 160px; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + overflow: hidden; + } + .mrmth-v2-gutter { + padding: 8px 10px; + min-width: 40px; + text-align: right; + user-select: none; + color: #9aa0a6; + background: rgba(0,0,0,0.35); + border-right: 1px solid rgba(255,255,255,0.12); + font-family: monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre; + box-sizing: border-box; + } + .mrmth-v2-textarea { + flex: 1; + min-height: 160px !important; + border: 0 !important; + outline: none !important; + resize: vertical !important; + font-family: monospace !important; + font-size: 12px !important; + line-height: 1.45 !important; + padding: 8px 10px !important; + box-sizing: border-box !important; + } + `; + document.head.appendChild(style); +} + +function hasNearbyScriptLabel(el) { + const container = el.closest("div, section, article"); + if (!container) return false; + + // text labels around the textarea + const candidates = container.querySelectorAll("label, .label, .input-label, .property_name, span, div"); + for (const n of candidates) { + const t = (n.textContent || "").trim().toLowerCase(); + if (t === "script") return true; + } + return false; +} + +function isLikelyScriptTextarea(el) { + if (!(el instanceof HTMLTextAreaElement)) return false; + if (el.dataset.mrmthV2Script === "1") return false; + + const attrs = [ + el.name, + el.id, + el.getAttribute("data-name"), + el.getAttribute("aria-label"), + el.getAttribute("placeholder"), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + // accept either direct attribute match OR nearby label + return attrs.includes("script") || hasNearbyScriptLabel(el); +} + +function updateGutter(textarea, gutter) { + const lineCount = Math.max(1, textarea.value.split("\n").length); + const lines = []; + for (let i = 1; i <= lineCount; i++) lines.push(String(i)); + gutter.textContent = lines.join("\n"); + gutter.style.transform = `translateY(${-textarea.scrollTop}px)`; +} + +function addBracketAutoClose(textarea) { + textarea.addEventListener("keydown", (e) => { + const key = e.key; + if (!OPENING_BRACKETS.has(key)) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + if (start !== end) return; + + const after = textarea.value[start] ?? ""; + const closing = BRACKET_PAIRS[key]; + + if (/[a-zA-Z0-9_]/.test(after) || after === closing) return; + + e.preventDefault(); + const before = textarea.value.slice(0, start); + const tail = textarea.value.slice(start); + textarea.value = `${before}${key}${closing}${tail}`; + textarea.selectionStart = textarea.selectionEnd = start + 1; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function hideNearbyScriptLabels(textarea) { + const root = textarea.closest("div, section, article") ?? textarea.parentElement; + if (!root) return; + + const labels = root.querySelectorAll("label, .label, .input-label, .property_name, .v-label, span, div"); + for (const el of labels) { + if (el.dataset.mrmthHiddenLabel === "1") continue; + const t = (el.textContent || "").trim().toLowerCase(); + if (t === "script") { + el.style.display = "none"; + el.dataset.mrmthHiddenLabel = "1"; + } + } +} + +function attachToTextarea(textarea) { + if (!isLikelyScriptTextarea(textarea)) return; + + const parent = textarea.parentElement; + if (!parent) return; + + ensureStyles(); + hideNearbyScriptLabels(textarea); + + const wrapper = document.createElement("div"); + wrapper.className = "mrmth-v2-editor"; + + const gutter = document.createElement("div"); + gutter.className = "mrmth-v2-gutter"; + + parent.replaceChild(wrapper, textarea); + wrapper.appendChild(gutter); + wrapper.appendChild(textarea); + + textarea.classList.add("mrmth-v2-textarea"); + textarea.dataset.mrmthV2Script = "1"; + + // disable red spell-check underline for code + textarea.spellcheck = false; + textarea.autocapitalize = "off"; + textarea.autocomplete = "off"; + textarea.autocorrect = "off"; + + const refresh = () => updateGutter(textarea, gutter); + textarea.addEventListener("input", refresh); + textarea.addEventListener("scroll", refresh); + + addBracketAutoClose(textarea); + refresh(); +} + +function scan(root = document) { + root.querySelectorAll("textarea").forEach(attachToTextarea); +} + +app.registerExtension({ + name: "mrmth.ScriptTextInput.V2", + async setup() { + scan(document); + + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches?.("textarea")) attachToTextarea(node); + scan(node); + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + }, +}); diff --git a/web/script_text_input.js b/web/script_text_input.js index 2067842..b48a366 100644 --- a/web/script_text_input.js +++ b/web/script_text_input.js @@ -1,72 +1,380 @@ import { app } from "/scripts/app.js"; -const NODE_IDS = new Set(["mrmth_ScriptInput"]); -const NODE_NAMES = new Set(["ScriptTextInput"]); -const STYLE_ID = "mrmth-script-line-numbers"; +const STYLE_ID = "mrmth-script-line-numbers-unified"; + +if (!window.__mrmthScriptInputUnifiedInit) { + window.__mrmthScriptInputUnifiedInit = true; + + function ensureStyles() { + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .mrmth-editor { + display: flex; + align-items: stretch; + width: 100%; + min-height: 170px; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 10px; + overflow: hidden; + background: rgba(0,0,0,0.18); + } + + .mrmth-gutter { + padding: 8px 10px; + min-width: 44px; + text-align: right; + user-select: none; + color: #9aa0a6; + background: rgba(0,0,0,0.35); + border-right: 1px solid rgba(255,255,255,0.12); + font-family: monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre; + box-sizing: border-box; + overflow: hidden; + } + + .mrmth-gutter-content { + white-space: pre; + will-change: transform; + } + + .mrmth-editor-container { + position: relative; + flex: 1; + min-width: 0; + min-height: 170px; + overflow: hidden; + } + + .mrmth-script-textarea { + display: block; + width: 100% !important; + height: 100% !important; + min-height: 170px !important; + margin: 0 !important; + border: 0 !important; + outline: none !important; + resize: vertical !important; + box-sizing: border-box !important; + + font-family: monospace !important; + font-size: 12px !important; + line-height: 1.45 !important; + padding: 8px 10px !important; + + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; + + z-index: 2; + color: transparent !important; + -webkit-text-fill-color: transparent; + caret-color: #ffffff !important; + background: transparent !important; + mix-blend-mode: lighten; + } + + .mrmth-measure { + position: absolute; + visibility: hidden; + pointer-events: none; + inset: 0 auto auto 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; + height: auto; + } + + .mrmth-syntax-layer { + position: absolute; + inset: 0; + margin: 0; + pointer-events: none; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; + z-index: 1; + mix-blend-mode: normal; + opacity: 1; + background: transparent; + overflow: hidden; + } + + .mrmth-token-comment { color: #7f8c8d; } + .mrmth-token-number { color: #f39c12; } + .mrmth-token-constant { color: #f1c40f; } + .mrmth-token-keyword { color: #e74c3c; } + .mrmth-token-function { color: #8e44ad; } + .mrmth-token-variable { color: #ecf0f1; } + .mrmth-token-operator { color: #95a5a6; } + .mrmth-token-string { color: #4ea658; } + .mrmth-token-bracket { font-weight: 700; } + .mrmth-bracket-0 { color: #ffd700; } + .mrmth-bracket-1 { color: #da70d6; } + .mrmth-bracket-2 { color: #87cefa; } + .mrmth-bracket-3 { color: #98fb98; } + .mrmth-bracket-4 { color: #ff6347; } + .mrmth-bracket-5 { color: #ffa500; } + `; + document.head.appendChild(style); + } + + function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function hasNearbyScriptLabel(el) { + const root = el.closest("div, section, article") ?? el.parentElement; + if (!root) return false; + const candidates = root.querySelectorAll("label, .label, .input-label, .property_name, .v-label, span, div"); + for (const n of candidates) { + const t = (n.textContent || "").trim().toLowerCase(); + if (t === "script") return true; + } + return false; + } + + function isLikelyScriptTextarea(el) { + if (!(el instanceof HTMLTextAreaElement)) return false; + if (el.dataset.mrmthEnhanced === "1") return false; + + const attrs = [ + el.name, + el.id, + el.getAttribute("data-name"), + el.getAttribute("aria-label"), + el.getAttribute("placeholder"), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return attrs.includes("script") || hasNearbyScriptLabel(el); + } + + function hideNearbyScriptLabels(textarea) { + const root = textarea.closest("div, section, article") ?? textarea.parentElement; + if (!root) return; + const labels = root.querySelectorAll("label, .label, .input-label, .property_name, .v-label, span, div"); + for (const el of labels) { + if (el.dataset.mrmthHiddenLabel === "1") continue; + const t = (el.textContent || "").trim().toLowerCase(); + if (t === "script") { + el.style.display = "none"; + el.dataset.mrmthHiddenLabel = "1"; + } + } + } + + function addBracketAutoClose(textarea) { + const pairs = { "(": ")", "[": "]", "{": "}" }; + const opening = new Set(Object.keys(pairs)); + + textarea.addEventListener("keydown", (e) => { + if (!opening.has(e.key)) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + if (start !== end) return; + + const nextChar = textarea.value[start] ?? ""; + const closing = pairs[e.key]; + if (/[a-zA-Z0-9_]/.test(nextChar) || nextChar === closing) return; + + e.preventDefault(); + textarea.value = `${textarea.value.slice(0, start)}${e.key}${closing}${textarea.value.slice(start)}`; + textarea.selectionStart = textarea.selectionEnd = start + 1; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + function getLineHeightPx(textarea) { + const style = getComputedStyle(textarea); + const lh = parseFloat(style.lineHeight); + if (Number.isFinite(lh) && lh > 0) return lh; + + const probe = document.createElement("span"); + probe.textContent = "A"; + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + probe.style.fontFamily = style.fontFamily; + probe.style.fontSize = style.fontSize; + probe.style.fontWeight = style.fontWeight; + probe.style.fontStyle = style.fontStyle; + document.body.appendChild(probe); + const h = probe.getBoundingClientRect().height || 16; + probe.remove(); + return h; + } + + function buildMeasureHtml(lines) { + let html = ""; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] === "" ? " " : lines[i]; + html += `${escapeHtml(line)}`; + if (i < lines.length - 1) html += "\n"; + } + return html; + } + + function updateNumbersWrapped(textarea, gutterContent, measureEl, lineHeightPx) { + const rawLines = textarea.value.split("\n"); + const lineCount = Math.max(1, rawLines.length); + + const scrollbarWidth = textarea.offsetWidth - textarea.clientWidth; + const widthPx = Math.max(0, textarea.clientWidth - scrollbarWidth); + + const cs = getComputedStyle(textarea); + measureEl.style.fontFamily = cs.fontFamily; + measureEl.style.fontSize = cs.fontSize; + measureEl.style.fontWeight = cs.fontWeight; + measureEl.style.fontStyle = cs.fontStyle; + measureEl.style.lineHeight = cs.lineHeight; + measureEl.style.padding = cs.padding; + measureEl.style.width = `${widthPx}px`; + + measureEl.innerHTML = buildMeasureHtml(rawLines); + const totalHeight = measureEl.offsetHeight; + + const gutterLines = []; + for (let i = 0; i < lineCount; i++) { + const marker = measureEl.querySelector(`[data-ln="${i}"]`); + const nextMarker = i < lineCount - 1 ? measureEl.querySelector(`[data-ln="${i + 1}"]`) : null; + + const y0 = marker ? marker.offsetTop : i * lineHeightPx; + const y1 = nextMarker ? nextMarker.offsetTop : totalHeight; + + const visualLines = Math.max(1, Math.round((y1 - y0) / lineHeightPx)); + gutterLines.push(String(i + 1)); + for (let j = 1; j < visualLines; j++) gutterLines.push(" "); + } + + gutterContent.textContent = gutterLines.join("\n"); + gutterContent.style.transform = `translateY(${-textarea.scrollTop}px)`; + } + + function attachToTextarea(textarea) { + if (!isLikelyScriptTextarea(textarea)) return; + if (textarea.closest(".mrmth-editor")) { + textarea.dataset.mrmthEnhanced = "1"; + return; + } + + const parent = textarea.parentElement; + if (!parent) return; + + ensureStyles(); + hideNearbyScriptLabels(textarea); + + const wrapper = document.createElement("div"); + wrapper.className = "mrmth-editor"; + + const gutter = document.createElement("div"); + gutter.className = "mrmth-gutter"; + + const gutterContent = document.createElement("div"); + gutterContent.className = "mrmth-gutter-content"; + gutter.appendChild(gutterContent); + + const editorContainer = document.createElement("div"); + editorContainer.className = "mrmth-editor-container"; + + const measureEl = document.createElement("pre"); + measureEl.className = "mrmth-measure"; + + parent.replaceChild(wrapper, textarea); + wrapper.appendChild(gutter); + wrapper.appendChild(editorContainer); + editorContainer.appendChild(measureEl); + editorContainer.appendChild(textarea); + + textarea.classList.add("mrmth-script-textarea"); + textarea.dataset.mrmthEnhanced = "1"; + textarea.spellcheck = false; + textarea.autocapitalize = "off"; + textarea.autocomplete = "off"; + textarea.autocorrect = "off"; + + textarea.style.color = "transparent"; + textarea.style.caretColor = "#ffffff"; + textarea.style.mixBlendMode = "lighten"; + textarea.style.background = "transparent"; + + addBracketAutoClose(textarea); + const lineHeightPx = getLineHeightPx(textarea); + + const refresh = () => updateNumbersWrapped(textarea, gutterContent, measureEl, lineHeightPx); + + textarea.addEventListener("input", refresh); + textarea.addEventListener("scroll", refresh); + + if (window.ResizeObserver) { + const ro = new ResizeObserver(refresh); + ro.observe(textarea); + } + + requestAnimationFrame(refresh); + } + + function scan(root = document) { + root.querySelectorAll("textarea").forEach(attachToTextarea); + } + + app.registerExtension({ + name: "mrmth.ScriptTextInput.Unified", + async setup() { + scan(document); + + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches?.("textarea")) attachToTextarea(node); + scan(node); + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + }, + }); +} const KEYWORDS = new Set(["if", "else", "while", "for", "in", "break", "continue", "return", "none", "None", "null", "NULL"]); const CONSTANTS = new Set(["pi", "PI", "e", "E"]); const FUNCTIONS = new Set([ "sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", - "abs", "sqrt", "ln", "log", "exp", "smin", "smax", "tmin", "tmax", "tnorm", "snorm", "floor", "ceil", - "round", "gamma", "pow", "sigm", "clamp", "fft", "ifft", "angle", "print", "print_shape", "pshp", "nvl", - "nan_to_num", "lerp", "step", "smoothstep", "fract", "relu", "softplus", "gelu", "sign", "map", "ezconvolution", - "ezconv", "convolution", "conv", "swap", "permute", "perm", "reshape", "rshp", "range", "topk", "botk", "pinv", - "sum", "mean", "std", "var", "quartile", "quartil", "percentile", "prcnt", "quantile", "dot", "moment","erf","erfinv", "any", - "all", "edge", "blur", "gaussian", "median", "mode", "cumsum", "cumprod", "topk_ind", "topk_indices", "botk_ind", - "botk_indices", "cubic_ease", "cubic", "elastic_ease", "elastic", "sine_ease", "sine", "smootherstep", "dist", - "distance", "remap", "cossim", "cosine_similarity", "count", "cnt", "length", "flatten", "append", "get_value", - "flow_apply", "batch_shuffle", "shuffle", "motion_mask", "flow_to_image", "overlay", "pad", "cross", "matmul", "rife", - "bnot", "bitwise_not", "bitcount", "popcount", "popcnt", "shape", "band", "bitwise_and", "bxor", "bitwise_xor", - "bor", "bitwise_or", "tensor", "stack_push", "stack_pop", "stack_clear", "stack_has", "stack_get", "timestamp","now", - "sort", "argsort", "argmin", "argmax", "softmax", "softmin", "unique", "flip", "cov", "corr", "correlation", "entropy", - "crop", "cat", "concatenate", "concat", "float","int", "linspace", "logspace", "roll","select", - "noise", "randn", "random_normal", "rand", "randu", "random_uniform", "randc", "random_cauchy", "rande", - "random_exponential", "randln", "random_log_normal", "randb", "random_bernoulli", "randp", "random_poisson", "randg", - "random_gamma", "randbeta", "random_beta", "randl", "random_laplace", "randgumbel", "random_gumbel", "randw", - "random_weibull", "randchi2", "random_chi2", "randt", "random_studentt", "perlin", "perlin_noise", "cellular", "voronoi", - "worley", "cellular_noise", "voronoi_noise", "plasma", "turbulence", "plasma_noise", - "upper", "lower", "split", "join", "substring", "substr", "find", "trim", "replace", - "dilate", "erode", "morph_open", "morph_close", - "rgb_to_hsv", "hsv_to_rgb", "int_to_rgb", "rgb_to_int", "where", "histogram", "hist", "flow_mag", "flow_magnitude", "flow_ang", "flow_angle", + "abs", "sqrt", "ln", "log", "exp", "floor", "ceil", "round", "pow", "clamp", + "fft", "ifft", "print", "where", "rgb_to_int", "int_to_rgb", "hsv_to_rgb", "rgb_to_hsv", + "sum", "mean", "std", "var", "min", "max", "sort", "argsort", "argmin", "argmax" ]); -const BRACKET_PAIRS = { - '(': ')', - '[': ']', - '{': '}' -}; -const OPENING_BRACKETS = new Set(['(', '[', '{']); -const CLOSING_BRACKETS = new Set([')', ']', '}']); - -function escapeHtml(value) { - return value.replace(/[&<>"']/g, (ch) => { - switch (ch) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - case "'": - return "'"; - default: - return ch; - } - }); -} +const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" }; +const OPENING_BRACKETS = new Set(["(", "[", "{"]); +const CLOSING_BRACKETS = new Set([")", "]", "}"]); function tokenize(text) { const tokens = []; const pattern = /#.*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?\b|\B\.\d+(?:[eE][+-]?\d+)?\b|==|!=|>=|<=|<<|>>|->|\+=|-=|\*=|\/=|%=|[+\-*/%^=<>|?:,;()\[\]{}]|\b[a-zA-Z_][a-zA-Z_0-9]*\b|\s+|./g; - let match; - const bracketStack = []; - const depthByType = { '(': 0, '[': 0, '{': 0 }; - while ((match = pattern.exec(text)) !== null) { - const value = match[0]; + const bracketStack = []; + const depthByType = { "(": 0, "[": 0, "{": 0 }; + let m; + + while ((m = pattern.exec(text)) !== null) { + const value = m[0]; let type = "text"; let depth = 0; @@ -80,20 +388,13 @@ function tokenize(text) { bracketStack.push(value); type = "bracket"; } else if (CLOSING_BRACKETS.has(value)) { - if (bracketStack.length > 0) { + if (bracketStack.length) { const lastOpen = bracketStack[bracketStack.length - 1]; if (BRACKET_PAIRS[lastOpen] === value) { - // Use depth of bracket type being closed, before decrement depth = depthByType[lastOpen] - 1; depthByType[lastOpen]--; bracketStack.pop(); - } else { - // Mismatched closing bracket - depth = 0; } - } else { - // Unmatched closing bracket - depth = 0; } type = "bracket"; } else if (/^[+\-*/%^=<>|?:,;]|==|!=|>=|<=|<<|>>|->$/.test(value)) { @@ -101,410 +402,24 @@ function tokenize(text) { } else if (/^(?:\d+\.\d*|\d+|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) { type = "number"; } else if (/^[a-zA-Z_]/.test(value)) { - if (KEYWORDS.has(value)) { - type = "keyword"; - } else if (CONSTANTS.has(value)) { - type = "constant"; - } else if (FUNCTIONS.has(value)) { - type = "function"; - } else { - type = "variable"; - } + if (KEYWORDS.has(value)) type = "keyword"; + else if (CONSTANTS.has(value)) type = "constant"; + else if (FUNCTIONS.has(value)) type = "function"; + else type = "variable"; } + tokens.push({ type, value, depth }); } return tokens; } function renderHighlight(text) { - const tokens = tokenize(text); - return tokens - .map((token) => { - if (token.type === "text") { - return escapeHtml(token.value); - } - if (token.type === "bracket") { - const depthClass = token.depth % 6; - return `${escapeHtml(token.value)}`; - } - return `${escapeHtml(token.value)}`; - }) - .join(""); + return tokenize(text).map((t) => { + if (t.type === "text") return escapeHtml(t.value); + if (t.type === "bracket") { + const d = t.depth % 6; + return `${escapeHtml(t.value)}`; + } + return `${escapeHtml(t.value)}`; + }).join(""); } - -function hasScriptInput(nodeData) { - const input = nodeData?.input || {}; - const required = input.required || {}; - const optional = input.optional || {}; - return Object.prototype.hasOwnProperty.call(required, "script") || Object.prototype.hasOwnProperty.call(optional, "script"); -} - -function isTargetNode(nodeData) { - if (!nodeData) { - return false; - } - if (NODE_IDS.has(nodeData.node_id)) { - return true; - } - if (NODE_IDS.has(nodeData.name)) { - return true; - } - if (NODE_NAMES.has(nodeData.name)) { - return true; - } - if (NODE_NAMES.has(nodeData.display_name)) { - return true; - } - return hasScriptInput(nodeData); -} - -function ensureStyles() { - if (document.getElementById(STYLE_ID)) { - return; - } - - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - .mrmth-line-editor { - display: flex; - align-items: stretch; - width: 100%; - height: 100%; - min-height: 100%; - gap: 0; - overflow: hidden; - } - .mrmth-line-numbers { - padding: 6px 8px; - text-align: right; - user-select: none; - color: #9aa0a6; - background: rgba(0, 0, 0, 0.4); - border-right: 1px solid rgba(255, 255, 255, 0.1); - font-family: monospace; - font-size: 12px; - line-height: 1.4; - white-space: pre; - overflow: hidden; - box-sizing: border-box; - } - .mrmth-line-numbers-content { - white-space: pre; - } - .mrmth-editor-container { - position: relative; - flex: 1; - min-height: 30px; - overflow: hidden; - } - .mrmth-line-input { - position: relative; - z-index: 2; - flex: 1; - width: 100%; - height: 100%; - box-sizing: border-box; - font-family: monospace; - line-height: 1.4; - white-space: pre-wrap; - word-wrap: break-word; - resize: none; - background: transparent; - color: transparent; - } - .mrmth-syntax-layer { - position: absolute; - inset: 0; - margin: 0; - z-index: 1; - pointer-events: none; - white-space: pre-wrap; - word-wrap: break-word; - overflow: hidden; - mix-blend-mode: normal; - opacity: 1; - } - .mrmth-token-comment { color: #7f8c8d; } - .mrmth-token-number { color: #f39c12; } - .mrmth-token-constant { color: #f1c40f; } - .mrmth-token-keyword { color: #e74c3c; } - .mrmth-token-function { color: #8e44ad; } - .mrmth-token-variable { color: #ecf0f1; } - .mrmth-token-operator { color: #95a5a6; } - .mrmth-token-string { color: #4ea658; } - .mrmth-token-bracket { font-weight: bold; } - .mrmth-bracket-0 { color: #ffd700; } - .mrmth-bracket-1 { color: #da70d6; } - .mrmth-bracket-2 { color: #87cefa; } - .mrmth-bracket-3 { color: #98fb98; } - .mrmth-bracket-4 { color: #ff6347; } - .mrmth-bracket-5 { color: #ffa500; } - `; - document.head.appendChild(style); -} - -function measureLineHeight(inputEl) { - const probe = document.createElement("span"); - const style = getComputedStyle(inputEl); - probe.style.position = "absolute"; - probe.style.visibility = "hidden"; - probe.style.whiteSpace = "pre"; - probe.style.fontFamily = style.fontFamily; - probe.style.fontSize = style.fontSize; - probe.style.fontWeight = style.fontWeight; - probe.style.fontStyle = style.fontStyle; - probe.style.lineHeight = style.lineHeight; - probe.textContent = "A"; - document.body.appendChild(probe); - const height = probe.getBoundingClientRect().height; - document.body.removeChild(probe); - return height; -} - -function attachLineNumbers(widget) { - const inputEl = widget?.inputEl; - if (!inputEl || inputEl.dataset.mrmthLineNumbers) { - return false; - } - - const parent = inputEl.parentElement; - if (!parent) { - return false; - } - - ensureStyles(); - - const wrapper = document.createElement("div"); - wrapper.className = "mrmth-line-editor"; - - const gutter = document.createElement("div"); - gutter.className = "mrmth-line-numbers"; - - const gutterContent = document.createElement("div"); - gutterContent.className = "mrmth-line-numbers-content"; - - const editorContainer = document.createElement("div"); - editorContainer.className = "mrmth-editor-container"; - - const syntaxLayer = document.createElement("pre"); - syntaxLayer.className = "mrmth-syntax-layer"; - - parent.replaceChild(wrapper, inputEl); - wrapper.appendChild(gutter); - gutter.appendChild(gutterContent); - wrapper.appendChild(editorContainer); - editorContainer.appendChild(syntaxLayer); - editorContainer.appendChild(inputEl); - - wrapper.style.height = "100%"; - parent.style.height = "100%"; - - inputEl.classList.add("mrmth-line-input"); - inputEl.dataset.mrmthLineNumbers = "true"; - - const inputStyle = getComputedStyle(inputEl); - const lineHeightPx = measureLineHeight(inputEl); - - const applyAlpha = (color, alpha) => { - const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i); - if (!rgbaMatch) { - return `rgba(0, 0, 0, ${alpha})`; - } - const r = Number(rgbaMatch[1]); - const g = Number(rgbaMatch[2]); - const b = Number(rgbaMatch[3]); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - }; - - const baseBackground = inputStyle.backgroundColor || "rgba(0, 0, 0, 0.4)"; - const overlayBackground = applyAlpha(baseBackground, 0.4); - - syntaxLayer.style.height = "100%"; - syntaxLayer.style.background = "transparent"; - inputEl.style.height = "100%"; - inputEl.style.background = overlayBackground; - - gutter.style.fontFamily = inputStyle.fontFamily; - gutter.style.fontSize = inputStyle.fontSize; - gutter.style.lineHeight = inputStyle.lineHeight; - gutter.style.paddingTop = inputStyle.paddingTop; - gutter.style.paddingBottom = inputStyle.paddingBottom; - - syntaxLayer.style.fontFamily = inputStyle.fontFamily; - syntaxLayer.style.fontSize = inputStyle.fontSize; - syntaxLayer.style.lineHeight = inputStyle.lineHeight; - syntaxLayer.style.padding = `${inputStyle.paddingTop} ${inputStyle.paddingRight} ${inputStyle.paddingBottom} ${inputStyle.paddingLeft}`; - syntaxLayer.style.width = "100%"; - syntaxLayer.style.boxSizing = "border-box"; - syntaxLayer.style.overflowY = "hidden"; - - inputEl.style.background = overlayBackground; - inputEl.style.color = "transparent"; - inputEl.style.caretColor = "#ffffff"; - inputEl.style.mixBlendMode = "lighten"; - - const updateNumbers = () => { - const rawLines = inputEl.value.split("\n"); - const textLineCount = Math.max(1, rawLines.length); - - const gutterLines = []; - - // Sync syntaxLayer width to match inputEl's actual content width - const scrollbarWidth = inputEl.offsetWidth - inputEl.clientWidth; - syntaxLayer.style.width = `calc(100% - ${scrollbarWidth}px)`; - - // Create a temporary clone - const measureClone = syntaxLayer.cloneNode(false); - measureClone.style.position = "absolute"; - measureClone.style.visibility = "hidden"; - measureClone.style.width = syntaxLayer.style.width; - measureClone.style.height = "auto"; - syntaxLayer.parentElement.appendChild(measureClone); - - // Build content with simple marker at start of each line - let html = ""; - for (let i = 0; i < textLineCount; i++) { - html += ``; - html += renderHighlight(rawLines[i] || " "); - if (i < textLineCount - 1) { - html += "\n"; - } - } - measureClone.innerHTML = html; - - // Simply read offsetTop of each marker - const totalHeight = measureClone.offsetHeight; - - for (let i = 0; i < textLineCount; i++) { - const marker = measureClone.querySelector(`[data-ln="${i}"]`); - const nextMarker = i < textLineCount - 1 ? measureClone.querySelector(`[data-ln="${i + 1}"]`) : null; - - const currentY = marker ? marker.offsetTop : i * lineHeightPx; - const nextY = nextMarker ? nextMarker.offsetTop : totalHeight; - - const heightDiff = nextY - currentY; - const visualLines = Math.max(1, Math.round(heightDiff / lineHeightPx)); - - gutterLines.push(String(i + 1)); - - for (let j = 1; j < visualLines; j++) { - gutterLines.push(" "); - } - } - - measureClone.remove(); - - gutterContent.textContent = gutterLines.join("\n"); - - gutter.style.height = `${inputEl.clientHeight}px`; - gutterContent.style.transform = `translateY(${-inputEl.scrollTop}px)`; - }; - - const updateHighlight = () => { - const sourceText = inputEl.value.endsWith("\n") ? `${inputEl.value} ` : inputEl.value; - const highlighted = renderHighlight(sourceText); - syntaxLayer.innerHTML = highlighted; - syntaxLayer.scrollTop = inputEl.scrollTop; - syntaxLayer.scrollLeft = inputEl.scrollLeft; - }; - - const handleBracketAutoClose = (e) => { - const key = e.key; - if (!OPENING_BRACKETS.has(key)) { - return; - } - - const selectionStart = inputEl.selectionStart; - const selectionEnd = inputEl.selectionEnd; - - if (selectionStart !== selectionEnd) { - return; - } - - const text = inputEl.value; - const charAfter = text[selectionStart] || ''; - - // Only prevent auto-close if the MATCHING closing bracket is after cursor - const closingBracket = BRACKET_PAIRS[key]; - const isMatchingClosingBracket = charAfter === closingBracket; - - // Prevent auto-close if next char is alphanumeric or a matching closing bracket - if (/[a-zA-Z0-9_]/.test(charAfter) || isMatchingClosingBracket) { - return; - } - - e.preventDefault(); - - const before = text.substring(0, selectionStart); - const after = text.substring(selectionStart); - inputEl.value = before + key + closingBracket + after; - inputEl.selectionStart = inputEl.selectionEnd = selectionStart + 1; - - updateNumbers(); - updateHighlight(); - - const event = new Event('input', { bubbles: true }); - inputEl.dispatchEvent(event); - }; - - inputEl.addEventListener("keydown", handleBracketAutoClose); - - inputEl.addEventListener("input", () => { - updateNumbers(); - updateHighlight(); - }); - inputEl.addEventListener("scroll", () => { - gutterContent.style.transform = `translateY(${-inputEl.scrollTop}px)`; - syntaxLayer.scrollTop = inputEl.scrollTop; - syntaxLayer.scrollLeft = inputEl.scrollLeft; - }); - - if (window.ResizeObserver) { - const resizeObserver = new ResizeObserver(() => { - updateNumbers(); - updateHighlight(); - }); - resizeObserver.observe(inputEl); - } - - requestAnimationFrame(() => { - updateNumbers(); - updateHighlight(); - }); - - return true; -} - -function attachLineNumbersWithRetry(node) { - const widget = node.widgets?.find((w) => w.name === "script"); - if (attachLineNumbers(widget)) { - return; - } - - requestAnimationFrame(() => { - const retryWidget = node.widgets?.find((w) => w.name === "script"); - attachLineNumbers(retryWidget); - }); -} - -app.registerExtension({ - name: "mrmth.ScriptTextInput.LineNumbers", - async beforeRegisterNodeDef(nodeType, nodeData) { - if (!isTargetNode(nodeData)) { - return; - } - - const onNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function () { - onNodeCreated?.apply(this, arguments); - attachLineNumbersWithRetry(this); - }; - - const onConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function () { - onConfigure?.apply(this, arguments); - attachLineNumbersWithRetry(this); - }; - }, -});