import { app } from "/scripts/app.js"; import { KEYWORDS, CONSTANTS, FUNCTIONS } from "./inbuilt_symbols.js"; import { attachAutocomplete } from "./autocomplete.js"; const STYLE_ID = "mrmth-script-line-numbers-unified"; function escapeHtml(value) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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-syntax-layer::after { content: ""; display: block; height: 1em; } .mrmth-gutter-content { white-space: pre; will-change: transform; } /* Make token spans allow breaking at the same points as plain text so highlighted layer wraps identically */ .mrmth-syntax-layer span, .mrmth-syntax-layer .mrmth-token-comment, .mrmth-syntax-layer .mrmth-token-string, .mrmth-syntax-layer .mrmth-token-variable, .mrmth-syntax-layer .mrmth-token-operator, .mrmth-syntax-layer .mrmth-token-bracket { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; hyphens: none; display: inline; } .mrmth-editor-container { position: relative; flex: 1; min-width: 0; min-height: 170px; overflow: hidden; background: transparent; /* ⬅️ DŮLEŽITÉ */ } .mrmth-syntax-layer { position: absolute; inset: 0; z-index: 3; /* ⬅️ výš než textarea */ pointer-events: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; box-sizing: border-box; overflow: hidden; opacity: 1; } .mrmth-script-textarea { position: absolute; /* ⬅️ ne relative */ inset: 0; z-index: 2; color: transparent !important; caret-color: #ffffff !important; background: transparent !important; mix-blend-mode: normal; /* ⬅️ ZRUŠ lighten */ -webkit-text-fill-color: transparent; /* Reserve scrollbar space to avoid layout jumps when it appears */ overflow-y: auto; scrollbar-gutter: stable; -webkit-overflow-scrolling: touch; } .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, .mrmth-measure { margin: 0 !important; } .mrmth-gutter-line { display: block; } .mrmth-baseline-probe { display:inline-block; width:0; height:0; line-height:0; overflow:hidden; } .mrmth-gutter-line { padding: 0 6px; } .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 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; if (el.closest('[data-mrmth-script-widget="vue"]')) 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 findBoundingAncestor(el) { let node = el.parentElement; while (node && node !== document.body && node !== document.documentElement) { const cs = getComputedStyle(node); // if ancestor has an explicit height or max-height or constrained overflow, use it as bounding box if ((cs.height && cs.height !== 'auto') || (cs.maxHeight && cs.maxHeight !== 'none') || (cs.overflowY && cs.overflowY !== 'visible')) { return node; } node = node.parentElement; } // fallback to viewport return document.documentElement; } function updateNumbersWrapped(textarea, gutterContent, measureEl, lineHeightPx) { const rawLines = textarea.value.split("\n"); const lineCount = Math.max(1, rawLines.length); // Use clientWidth directly for mirror width to avoid rewrap when scrollbar appears/disappears const widthPx = textarea.clientWidth; 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(" "); } // Render gutter lines as elements so we can measure baseline correctly gutterContent.innerHTML = gutterLines.map(l => `
${escapeHtml(l)}
`).join(""); // stable baseline alignment using offsetTop sums (measure marker + probe) to avoid viewport jitter const firstMarker = measureEl.querySelector(`[data-ln="0"]`); const gutterFirstLine = gutterContent.firstElementChild; // ensure probes exist let measureProbe = firstMarker ? firstMarker.querySelector('.mrmth-baseline-probe') : null; if (!measureProbe && firstMarker) { measureProbe = document.createElement('span'); measureProbe.className = 'mrmth-baseline-probe'; measureProbe.style.display = 'inline-block'; measureProbe.style.width = '0px'; measureProbe.style.height = '0px'; measureProbe.style.lineHeight = '0'; measureProbe.style.verticalAlign = 'baseline'; measureProbe.style.overflow = 'hidden'; firstMarker.appendChild(measureProbe); } let gutterProbe = gutterFirstLine ? gutterFirstLine.querySelector('.mrmth-baseline-probe') : null; if (!gutterProbe && gutterFirstLine) { gutterProbe = document.createElement('span'); gutterProbe.className = 'mrmth-baseline-probe'; gutterProbe.style.display = 'inline-block'; gutterProbe.style.width = '0px'; gutterProbe.style.height = '0px'; gutterProbe.style.lineHeight = '0'; gutterProbe.style.verticalAlign = 'baseline'; gutterProbe.style.overflow = 'hidden'; gutterFirstLine.insertAdjacentElement('afterbegin', gutterProbe); } // Prefer baseline positions computed relative to each container to avoid global jumps let baselineDelta; try { if (measureProbe && gutterProbe) { const mProbeRect = measureProbe.getBoundingClientRect(); const gProbeRect = gutterProbe.getBoundingClientRect(); const mRect = measureEl.getBoundingClientRect(); const gRect = gutterContent.getBoundingClientRect(); // probe offsets relative to their own containers const mOffset = mProbeRect.top - mRect.top; const gOffset = gProbeRect.top - gRect.top; baselineDelta = mOffset - gOffset; } else { const measureBaseline = (firstMarker ? firstMarker.offsetTop : 0) + (measureProbe ? measureProbe.offsetTop : 0); const gutterBaseline = (gutterFirstLine ? gutterFirstLine.offsetTop : 0) + (gutterProbe ? gutterProbe.offsetTop : 0); baselineDelta = measureBaseline - gutterBaseline; } } catch (e) { // fallback to previous offsetTop-based method baselineDelta = (firstMarker ? firstMarker.offsetTop : 0) + (measureProbe ? measureProbe.offsetTop : 0) - ((gutterFirstLine ? gutterFirstLine.offsetTop : 0) + (gutterProbe ? gutterProbe.offsetTop : 0)); } // If gutterBaseline couldn't be measured for some reason, fall back to marker offset if (!gutterFirstLine) baselineDelta = (firstMarker ? firstMarker.offsetTop : 0) + (measureProbe ? measureProbe.offsetTop : 0); // Clamp and smooth extreme jumps to avoid the gutter flying away on transient layout changes const prevOffset = parseFloat(gutterContent.dataset.mrmthOffset || "0"); let stableCount = parseInt(gutterContent.dataset.mrmthStableCount || "0", 10) || 0; const locked = gutterContent.dataset.mrmthLocked === "1"; const maxJumpPerFrame = 20; // px if (locked) { // keep previous locked offset baselineDelta = prevOffset; } else { if (!Number.isFinite(baselineDelta)) baselineDelta = prevOffset; const jump = baselineDelta - prevOffset; if (Math.abs(jump) > maxJumpPerFrame) baselineDelta = prevOffset + Math.sign(jump) * maxJumpPerFrame; // absolute sanity clamp if (Math.abs(baselineDelta) > 200) baselineDelta = prevOffset; const postJump = baselineDelta - prevOffset; if (Math.abs(postJump) <= 1) stableCount++; else stableCount = 0; gutterContent.dataset.mrmthStableCount = String(stableCount); if (stableCount >= 3) gutterContent.dataset.mrmthLocked = "1"; } // Keep gutter padding in sync with textarea to match vertical origin across node variants gutterContent.style.padding = cs.padding || '0'; // Prefer computed CSS line-height when available, otherwise fall back to measured px const parsedLH = parseFloat(cs.lineHeight); gutterContent.style.lineHeight = Number.isFinite(parsedLH) && parsedLH > 0 ? cs.lineHeight : `${lineHeightPx}px`; gutterContent.style.boxSizing = 'border-box'; gutterContent.style.fontFamily = cs.fontFamily; gutterContent.style.fontSize = cs.fontSize; gutterContent.style.fontWeight = cs.fontWeight; gutterContent.style.fontStyle = cs.fontStyle; if (window.__mrmthGutterDebug) { const textareaRect = textarea.getBoundingClientRect(); const measureRect = measureEl.getBoundingClientRect(); const gutterRect = gutterContent.getBoundingClientRect(); const firstMarkerTop = firstMarker ? firstMarker.offsetTop : null; const measureProbeTop = measureProbe ? measureProbe.offsetTop : null; const gutterFirstLineTop = gutterFirstLine ? gutterFirstLine.offsetTop : null; const gutterProbeTop = gutterProbe ? gutterProbe.offsetTop : null; const computedTranslate = baselineDelta - textarea.scrollTop; console.log("mrmth-gutter-debug", { textareaRect, measureRect, gutterRect, lineHeightPx, lineCount, widthPx, totalHeight, firstMarkerTop, measureProbeTop, gutterFirstLineTop, gutterProbeTop, baselineDelta, computedTranslate, prevOffset, stableCount, locked }); } // Apply half-line correction: shift gutter up by half the line height so numbers align with text baseline const parsedLHFinal = parseFloat(cs.lineHeight); const lineHUsedFinal = Number.isFinite(parsedLHFinal) && parsedLHFinal > 0 ? parsedLHFinal : lineHeightPx; const halfLineCorrection = lineHUsedFinal / 2; const adjustedBaseline = baselineDelta - halfLineCorrection; // Apply transform on next frame to avoid layout thrash gutterContent.dataset.mrmthOffset = String(adjustedBaseline); requestAnimationFrame(() => { gutterContent.style.transform = `translateY(${adjustedBaseline - 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"; const syntaxLayer = document.createElement("pre"); syntaxLayer.className = "mrmth-syntax-layer"; parent.replaceChild(wrapper, textarea); wrapper.appendChild(gutter); wrapper.appendChild(editorContainer); editorContainer.appendChild(measureEl); editorContainer.appendChild(syntaxLayer); 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"; addBracketAutoClose(textarea); attachAutocomplete(textarea, editorContainer); const cs = getComputedStyle(textarea); syntaxLayer.style.fontFamily = cs.fontFamily; syntaxLayer.style.fontSize = cs.fontSize; syntaxLayer.style.fontWeight = cs.fontWeight; syntaxLayer.style.fontStyle = cs.fontStyle; syntaxLayer.style.lineHeight = cs.lineHeight; syntaxLayer.style.padding = cs.padding; 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; gutterContent.style.fontFamily = cs.fontFamily; gutterContent.style.fontSize = cs.fontSize; gutterContent.style.fontWeight = cs.fontWeight; gutterContent.style.fontStyle = cs.fontStyle; gutterContent.style.padding = cs.padding; gutterContent.style.boxSizing = 'border-box'; gutterContent.style.margin = '0'; gutterContent.style.whiteSpace = 'pre'; gutterContent.style.lineHeight = cs.lineHeight; // Ensure syntax layer wraps characters the same way as measure layer syntaxLayer.style.whiteSpace = 'pre-wrap'; syntaxLayer.style.overflowWrap = 'anywhere'; syntaxLayer.style.wordBreak = 'break-word'; syntaxLayer.style.boxSizing = 'border-box'; syntaxLayer.style.display = 'block'; const lineHeightPx = getLineHeightPx(textarea); const refresh = () => { // keep measure and syntax layers using the exact textarea content width to ensure identical wrapping const widthPx = textarea.clientWidth; measureEl.style.width = `${widthPx}px`; syntaxLayer.style.width = `${widthPx}px`; syntaxLayer.style.boxSizing = 'border-box'; updateNumbersWrapped(textarea, gutterContent, measureEl, lineHeightPx); updateHighlight(textarea, syntaxLayer); // Reactive height: size wrapper and editor container to content, clamped to parent's height const contentHeight = measureEl.offsetHeight || (lineHeightPx * Math.max(1, textarea.value.split('\n').length)); const bounding = findBoundingAncestor(wrapper) || parent || document.documentElement; const parentHeight = bounding && bounding.clientHeight ? bounding.clientHeight : null; const minHeight = 60; let maxHeight = 600; if (parentHeight !== null) { // leave some padding inside parent maxHeight = Math.max(60, parentHeight - 12); // special-case: if bounding is the documentElement (viewport), allow growth until viewport if (bounding === document.documentElement) { // but guard against ancestor that resizes with content (V2). If wrapper's parent adapts to content, find a higher static ancestor const staticAncestor = findBoundingAncestor(bounding); if (staticAncestor && staticAncestor !== document.documentElement) { maxHeight = Math.max(60, staticAncestor.clientHeight - 12); } } } const desired = Math.min(Math.max(contentHeight, minHeight), maxHeight); // ensure wrapper and editor container fill the desired height try { wrapper.style.minHeight = '170px'; // FIX: If the parent container is larger than the desired content height (e.g. node was enlarged/resized), // allow it to occupy 100% of the parent height. Otherwise, let it match content size. if (parentHeight && parentHeight > desired + 20) { wrapper.style.height = '100%'; editorContainer.style.height = '100%'; } else { wrapper.style.height = desired + 'px'; editorContainer.style.height = desired + 'px'; } } catch (e) { // ignore if elements unavailable } }; // Force initial measurement again after a short delay to allow different node wrappers to settle setTimeout(refresh, 50); setTimeout(refresh, 200); textarea.addEventListener("input", refresh); textarea.addEventListener("scroll", () => { const base = parseFloat(gutterContent.dataset.mrmthOffset) || 0; gutterContent.style.transform = `translateY(${base - textarea.scrollTop}px)`; syntaxLayer.scrollTop = textarea.scrollTop; syntaxLayer.scrollLeft = textarea.scrollLeft; }); // Also keep gutter stable on mouse enter/leave which previously caused jumps gutter.addEventListener('mouseenter', () => requestAnimationFrame(refresh)); gutter.addEventListener('mouseleave', () => requestAnimationFrame(refresh)); if (window.ResizeObserver) { const ro = new ResizeObserver(refresh); ro.observe(editorContainer); } requestAnimationFrame(refresh); } function updateHighlight(textarea, syntaxLayer) { syntaxLayer.innerHTML = renderHighlight(textarea.value); syntaxLayer.scrollTop = textarea.scrollTop; syntaxLayer.scrollLeft = textarea.scrollLeft; } function scan(root = document) { root.querySelectorAll("textarea").forEach(attachToTextarea); } app.registerExtension({ name: "mrmth.ScriptTextInput.Unified", nodeCreated(node, app) { if (node.comfyClass === "mrmth_ScriptInput" || node.type === "mrmth_ScriptInput") { const widget = node.widgets?.find(w => w.name === "script"); if (widget) { const isVue = app.ui?.settings?.getSettingValue?.("Comfy.VueNodes.Enabled") || document.getElementById("vue-app"); if (isVue) { widget.type = "scriptinput"; } } } }, 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 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; 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; 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) { const lastOpen = bracketStack[bracketStack.length - 1]; if (BRACKET_PAIRS[lastOpen] === value) { depth = depthByType[lastOpen] - 1; depthByType[lastOpen]--; bracketStack.pop(); } } 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) { 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(""); }