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);
- };
- },
-});