AI: more functions to frontend

This commit is contained in:
mcDandy
2026-10-17 22:26:39 +02:00
parent 67fb66e68c
commit 7d89a79818
8 changed files with 967 additions and 1193 deletions
+11 -11
View File
@@ -209,17 +209,17 @@ function Get-FunctionTokenNames {
return $names
}
function Get-UniqueLowerWords {
# De-duplicates literal alternatives that only differ by case (e.g.
# 'pi'/'PI', 'none'/'None'/'null'/'NULL') down to one lowercase spelling
# per distinct word, preserving first-seen order.
function Get-UniqueWords {
# De-duplicates literal alternatives, preserving first-seen order. Case
# variants (e.g. 'pi'/'PI', 'none'/'None'/'null'/'NULL') are all kept,
# since the lexer is case-sensitive and the web highlighter must
# recognise every spelling the grammar accepts.
param([string[]]$Words)
$seen = New-Object System.Collections.Generic.HashSet[string]
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal)
$result = New-Object System.Collections.Generic.List[string]
foreach ($w in $Words) {
$lw = $w.ToLowerInvariant()
if ($seen.Add($lw)) { [void]$result.Add($lw) }
if ($seen.Add($w)) { [void]$result.Add($w) }
}
return $result
}
@@ -327,7 +327,7 @@ $functionTokenToWords = [ordered]@{}
$tokenNames = @($allTokens.psbase.Keys)
foreach ($name in $tokenNames) {
$words = Get-UniqueLowerWords -Words $allTokens[$name]
$words = Get-UniqueWords -Words $allTokens[$name]
if ($name -eq 'CONSTANT') {
$constantWords = $words
continue
@@ -340,9 +340,9 @@ foreach ($name in $tokenNames) {
}
}
$KEYWORDS = @($keywordWords | Sort-Object -Unique)
$CONSTANTS = @($constantWords | Sort-Object -Unique)
$FUNCTIONS = @($functionWords | Sort-Object -Unique)
$KEYWORDS = @($keywordWords | Sort-Object -Unique -CaseSensitive)
$CONSTANTS = @($constantWords | Sort-Object -Unique -CaseSensitive)
$FUNCTIONS = @($functionWords | Sort-Object -Unique -CaseSensitive)
$docEntries = Get-FunctionDocEntries -FuncSection $sections.FuncSection
$sigByToken = @{}
+3 -3
View File
@@ -1,12 +1,12 @@
# Generated automatically by compile.ps1. Do not edit.
INBUILT_KEYWORDS = {
'break', 'continue', 'else', 'for', 'if', 'in', 'none', 'null', 'return', 'while'
'break', 'continue', 'else', 'for', 'if', 'in', 'none', 'None', 'null', 'NULL', 'return', 'while'
}
INBUILT_CONSTANTS = {
'e', 'pi'
'e', 'E', 'pi', 'PI'
}
INBUILT_FUNCTIONS = {
'abs', 'acos', 'acosh', 'add_key', 'added_keys', 'all', 'allclose', 'angle', 'any', 'append', 'argmax', 'argmin', 'argsort', 'as_nested', 'as_nested_tensor', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'band', 'batch_shuffle', 'bitcount', 'bitwise_and', 'bitwise_not', 'bitwise_or', 'bitwise_xor', 'blur', 'bnot', 'bor', 'botk', 'botk_ind', 'botk_indices', 'brown', 'brown_noise', 'bxor', 'cat', 'cbrt', 'ceil', 'cellular', 'cellular_noise', 'cielab_to_rgb', 'clamp', 'cnt', 'concat', 'concatenate', 'conv', 'convolution', 'coordinates', 'coords', 'corr', 'correlation', 'cos', 'cosh', 'cosine_similarity', 'cossim', 'count', 'cov', 'crop', 'cross', 'cross_entropy', 'cubic', 'cubic_ease', 'cumprod', 'cumsum', 'degrees', 'det', 'diag', 'diagonal_matrix', 'dilate', 'disable_tracking', 'dist', 'distance', 'domain_warp', 'domain_warp_noise', 'dot', 'edge', 'elastic', 'elastic_ease', 'enable_tracking', 'endswith', 'entropy', 'erf', 'erfc', 'erfinv', 'erode', 'exp', 'expm1', 'eye', 'ezconv', 'ezconvolution', 'fft', 'find', 'flatten', 'flip', 'float', 'floor', 'flow_ang', 'flow_angle', 'flow_apply', 'flow_mag', 'flow_magnitude', 'flow_to_image', 'fract', 'gamma', 'gather', 'gaussian', 'gelu', 'get_added_keys', 'get_changed_keys', 'get_removed_keys', 'get_unchanged_keys', 'get_value', 'group_norm', 'hist', 'histogram', 'hsv_to_rgb', 'huber_loss', 'hypot', 'changed_keys', 'ifft', 'int', 'int_to_rgb', 'interpolate_area', 'interpolate_linear', 'interpolate_nearest', 'interpolate_nearest_exact', 'inv', 'irfft', 'isclose', 'isfinite', 'isinf', 'isnan', 'join', 'keys', 'leaky_relu', 'length', 'lerp', 'linspace', 'ln', 'log', 'log1p', 'log2', 'logaddexp', 'logical_and', 'logical_not', 'logical_or', 'logspace', 'logsumexp', 'lower', 'mae', 'map', 'matmul', 'matrix_rank', 'mean', 'median', 'mode', 'moment', 'morph_close', 'morph_open', 'motion_mask', 'mse', 'nan_to_num', 'noise', 'nonzero', 'norm', 'normalize', 'now', 'nvl', 'oklab_to_rgb', 'one_hot', 'ones', 'outer', 'overlay', 'pad', 'percentile', 'perlin', 'perlin_noise', 'perm', 'permute', 'pink', 'pink_noise', 'pinv', 'plasma', 'plasma_noise', 'popcnt', 'popcount', 'pow', 'prcnt', 'print', 'print_shape', 'prod', 'pshp', 'psnr', 'quantile', 'quartil', 'quartile', 'radians', 'rand', 'randb', 'randbeta', 'randc', 'rande', 'randg', 'randgumbel', 'randchi2', 'randl', 'randln', 'randn', 'random_bernoulli', 'random_beta', 'random_cauchy', 'random_exponential', 'random_gamma', 'random_gumbel', 'random_chi2', 'random_laplace', 'random_log_normal', 'random_normal', 'random_poisson', 'random_studentt', 'random_uniform', 'random_weibull', 'randp', 'randt', 'randu', 'randw', 'range', 'reciprocal', 'relu', 'remap', 'remove_key', 'removed_keys', 'repeat', 'replace', 'reset_tracking', 'reshape', 'rfft', 'rgb_to_cielab', 'rgb_to_hsv', 'rgb_to_int', 'rgb_to_oklab', 'ridged', 'ridged_noise', 'rife', 'rm_key', 'roll', 'round', 'rshp', 'scatter', 'scatter_add', 'select', 'shape', 'shuffle', 'sigm', 'sign', 'silu', 'sin', 'sinc', 'sine', 'sine_ease', 'singular_value_decomposition', 'sinh', 'slerp', 'smax', 'smin', 'smootherstep', 'smoothstep', 'snorm', 'softmax', 'softmin', 'softplus', 'softsign', 'solve', 'sort', 'split', 'sqrt', 'squeeze', 'stack', 'stack_clear', 'stack_get', 'stack_has', 'stack_pop', 'stack_push', 'startswith', 'std', 'step', 'substr', 'substring', 'sum', 'svd', 'swap', 'swapaxes', 'tan', 'tanh', 'tensor', 'text_image', 'timestamp', 'tmax', 'tmin', 'tnorm', 'topk', 'topk_ind', 'topk_indices', 'trace', 'track_off', 'track_on', 'track_reset', 'transpose', 'trim', 'turbulence', 'unchanged_keys', 'unique', 'unsqueeze', 'upper', 'var', 'voronoi', 'voronoi_noise', 'weighted_mean', 'where', 'worley', 'zeros'
'abs', 'acos', 'acosh', 'add_key', 'added_keys', 'all', 'allclose', 'angle', 'any', 'append', 'argmax', 'argmin', 'argsort', 'as_nested', 'as_nested_tensor', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'band', 'batch_shuffle', 'bitcount', 'bitwise_and', 'bitwise_not', 'bitwise_or', 'bitwise_xor', 'blur', 'bnot', 'bor', 'botk', 'botk_ind', 'botk_indices', 'brown', 'brown_noise', 'bxor', 'cat', 'cbrt', 'ceil', 'cellular', 'cellular_noise', 'changed_keys', 'cielab_to_rgb', 'clamp', 'cnt', 'concat', 'concatenate', 'conv', 'convolution', 'coordinates', 'coords', 'corr', 'correlation', 'cos', 'cosh', 'cosine_similarity', 'cossim', 'count', 'cov', 'crop', 'cross', 'cross_entropy', 'cubic', 'cubic_ease', 'cumprod', 'cumsum', 'degrees', 'det', 'diag', 'diagonal_matrix', 'dilate', 'disable_tracking', 'dist', 'distance', 'domain_warp', 'domain_warp_noise', 'dot', 'edge', 'elastic', 'elastic_ease', 'enable_tracking', 'endswith', 'entropy', 'erf', 'erfc', 'erfinv', 'erode', 'exp', 'expm1', 'eye', 'ezconv', 'ezconvolution', 'fft', 'find', 'flatten', 'flip', 'float', 'floor', 'flow_ang', 'flow_angle', 'flow_apply', 'flow_mag', 'flow_magnitude', 'flow_to_image', 'fract', 'gamma', 'gather', 'gaussian', 'gelu', 'get_added_keys', 'get_changed_keys', 'get_removed_keys', 'get_unchanged_keys', 'get_value', 'group_norm', 'hist', 'histogram', 'hsv_to_rgb', 'huber_loss', 'hypot', 'ifft', 'int', 'int_to_rgb', 'interpolate_area', 'interpolate_linear', 'interpolate_nearest', 'interpolate_nearest_exact', 'inv', 'irfft', 'isclose', 'isfinite', 'isinf', 'isnan', 'join', 'keys', 'leaky_relu', 'length', 'lerp', 'linspace', 'ln', 'log', 'log1p', 'log2', 'logaddexp', 'logical_and', 'logical_not', 'logical_or', 'logspace', 'logsumexp', 'lower', 'mae', 'map', 'matmul', 'matrix_rank', 'mean', 'median', 'mode', 'moment', 'morph_close', 'morph_open', 'motion_mask', 'mse', 'nan_to_num', 'noise', 'nonzero', 'norm', 'normalize', 'now', 'nvl', 'oklab_to_rgb', 'one_hot', 'ones', 'outer', 'overlay', 'pad', 'percentile', 'perlin', 'perlin_noise', 'perm', 'permute', 'pink', 'pink_noise', 'pinv', 'plasma', 'plasma_noise', 'popcnt', 'popcount', 'pow', 'prcnt', 'print', 'print_shape', 'prod', 'pshp', 'psnr', 'quantile', 'quartil', 'quartile', 'radians', 'rand', 'randb', 'randbeta', 'randc', 'randchi2', 'rande', 'randg', 'randgumbel', 'randl', 'randln', 'randn', 'random_bernoulli', 'random_beta', 'random_cauchy', 'random_chi2', 'random_exponential', 'random_gamma', 'random_gumbel', 'random_laplace', 'random_log_normal', 'random_normal', 'random_poisson', 'random_studentt', 'random_uniform', 'random_weibull', 'randp', 'randt', 'randu', 'randw', 'range', 'reciprocal', 'relu', 'remap', 'remove_key', 'removed_keys', 'repeat', 'replace', 'reset_tracking', 'reshape', 'rfft', 'rgb_to_cielab', 'rgb_to_hsv', 'rgb_to_int', 'rgb_to_oklab', 'ridged', 'ridged_noise', 'rife', 'rm_key', 'roll', 'round', 'rshp', 'scatter', 'scatter_add', 'select', 'shape', 'shuffle', 'sigm', 'sign', 'silu', 'sin', 'sinc', 'sine', 'sine_ease', 'singular_value_decomposition', 'sinh', 'slerp', 'smax', 'smin', 'smootherstep', 'smoothstep', 'snorm', 'softmax', 'softmin', 'softplus', 'softsign', 'solve', 'sort', 'split', 'sqrt', 'squeeze', 'stack', 'stack_clear', 'stack_get', 'stack_has', 'stack_pop', 'stack_push', 'startswith', 'std', 'step', 'substr', 'substring', 'sum', 'svd', 'swap', 'swapaxes', 'tan', 'tanh', 'tensor', 'text_image', 'timestamp', 'tmax', 'tmin', 'tnorm', 'topk', 'topk_ind', 'topk_indices', 'trace', 'track_off', 'track_on', 'track_reset', 'transpose', 'trim', 'turbulence', 'unchanged_keys', 'unique', 'unsqueeze', 'upper', 'var', 'voronoi', 'voronoi_noise', 'weighted_mean', 'where', 'worley', 'zeros'
}
INBUILT_FUNCTION_META = {
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Quick test to verify bitwise operations work with tensors"""
import sys
sys.path.insert(0, r'D:\stability\Data\Packages\ComfyUI')
import torch
from custom_nodes.more_math.more_math.Parser.UnifiedMathVisitor import UnifiedMathVisitor
def test_bitwise_xor_float():
"""Test XOR with float tensors (the reported bug)"""
print("Testing bitwise XOR with float tensors...")
# Create float tensors (this was causing the error)
a = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float32)
b = torch.tensor([4.0, 5.0, 6.0], dtype=torch.float32)
visitor = UnifiedMathVisitor({"a": a, "b": b})
try:
# This should convert to int64, perform XOR, then convert back
result = visitor._bitwise_op(a, b, torch.bitwise_xor, lambda x, y: x ^ y)
print("✓ XOR succeeded!")
print(f" Input a (float32): {a}")
print(f" Input b (float32): {b}")
print(f" Result: {result}")
print(f" Result dtype: {result.dtype}")
return True
except Exception as e:
print(f"✗ XOR failed: {e}")
return False
def test_bitwise_and_float():
"""Test AND with float tensors"""
print("\nTesting bitwise AND with float tensors...")
a = torch.tensor([15.0, 14.0, 13.0], dtype=torch.float32)
b = torch.tensor([7.0, 3.0, 1.0], dtype=torch.float32)
visitor = UnifiedMathVisitor({"a": a, "b": b})
try:
result = visitor._bitwise_op(a, b, torch.bitwise_and, lambda x, y: x & y)
print("✓ AND succeeded!")
print(f" Input a (float32): {a}")
print(f" Input b (float32): {b}")
print(f" Result: {result}")
print(f" Result dtype: {result.dtype}")
return True
except Exception as e:
print(f"✗ AND failed: {e}")
return False
def test_bitwise_or_float():
"""Test OR with float tensors"""
print("\nTesting bitwise OR with float tensors...")
a = torch.tensor([15.0, 14.0, 13.0], dtype=torch.float32)
b = torch.tensor([7.0, 3.0, 1.0], dtype=torch.float32)
visitor = UnifiedMathVisitor({"a": a, "b": b})
try:
result = visitor._bitwise_op(a, b, torch.bitwise_or, lambda x, y: x | y)
print("✓ OR succeeded!")
print(f" Input a (float32): {a}")
print(f" Input b (float32): {b}")
print(f" Result: {result}")
print(f" Result dtype: {result.dtype}")
return True
except Exception as e:
print(f"✗ OR failed: {e}")
return False
def test_bitwise_not_float():
"""Test NOT with float tensors"""
print("\nTesting bitwise NOT with float tensors...")
a = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float32)
visitor = UnifiedMathVisitor({"a": a})
try:
result = visitor._bitwise_not(a)
print("✓ NOT succeeded!")
print(f" Input a (float32): {a}")
print(f" Result: {result}")
print(f" Result dtype: {result.dtype}")
return True
except Exception as e:
print(f"✗ NOT failed: {e}")
return False
def test_int_tensors_preserved():
"""Ensure int tensors still work as before"""
print("\nTesting that int tensor dtypes are preserved...")
a = torch.tensor([15, 14, 13], dtype=torch.int16)
b = torch.tensor([7, 3, 1], dtype=torch.int16)
visitor = UnifiedMathVisitor({"a": a, "b": b})
try:
result = visitor._bitwise_op(a, b, torch.bitwise_and, lambda x, y: x & y)
assert result.dtype == torch.int16, f"Expected int16, got {result.dtype}"
print("✓ Int16 dtype preserved!")
print(f" Input a (int16): {a}")
print(f" Input b (int16): {b}")
print(f" Result (int16): {result}")
return True
except Exception as e:
print(f"✗ Int16 test failed: {e}")
return False
if __name__ == "__main__":
print("=" * 70)
print("Bitwise Operations Fix Verification")
print("=" * 70)
results = [
test_bitwise_xor_float(),
test_bitwise_and_float(),
test_bitwise_or_float(),
test_bitwise_not_float(),
test_int_tensors_preserved(),
]
print("\n" + "=" * 70)
if all(results):
print("✓ All tests passed!")
else:
print("✗ Some tests failed")
sys.exit(1)
print("=" * 70)
View File
+114 -376
View File
@@ -2,25 +2,10 @@
<div
class="mrmth-editor mrmth-editor-v2"
data-mrmth-script-widget="vue"
ref="wrapperRef"
:style="editorStyle"
>
<!-- Gutter / Line Numbers -->
<div class="mrmth-gutter">
<div
ref="gutterContentRef"
class="mrmth-gutter-content"
:style="gutterStyle"
>
<div
v-for="(line, idx) in gutterLines"
:key="idx"
class="mrmth-gutter-line"
>
<span v-if="line === ' '" class="mrmth-baseline-probe"></span>
{{ line }}
</div>
</div>
<div class="mrmth-gutter-content" :style="gutterStyle">{{ gutterText }}</div>
</div>
<!-- Editor Container -->
@@ -46,15 +31,13 @@
ref="textareaRef"
v-model="modelValue"
class="mrmth-script-textarea"
:style="sharedTextStyle"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
placeholder="Enter your script here..."
@input="onInput"
@scroll="onScroll"
@keydown="onKeyDown"
@blur="cursor = -2"
></textarea>
</div>
</div>
@@ -62,390 +45,138 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { KEYWORDS, CONSTANTS, FUNCTIONS } from './inbuilt_symbols.js';
import { attachAutocomplete } from './autocomplete.js';
import { attachAutocomplete, escapeHtml, handleEditingKey, renderHighlight } from './autocomplete.js';
// Define model for value binding
const modelValue = defineModel<string>({ default: '' });
// Refs for DOM elements
const wrapperRef = ref<HTMLDivElement | null>(null);
const gutterContentRef = ref<HTMLDivElement | null>(null);
const editorContainerRef = ref<HTMLDivElement | null>(null);
const measureElRef = ref<HTMLPreElement | null>(null);
const syntaxLayerRef = ref<HTMLPreElement | null>(null);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
// Reactive state
const gutterLines = ref<string[]>(['1']);
const editorHeight = ref<string>('100%');
const scrollTop = ref<number>(0);
const scrollLeft = ref<number>(0);
const textareaWidth = ref<number>(0);
const gutterText = ref('1');
const scrollTop = ref(0);
// Collapsed caret position while focused, used to outline the bracket pair at the cursor.
const cursor = ref(-2);
// Style variables to sync with textarea computed styles
const textareaStyles = ref({
fontFamily: 'monospace',
fontSize: '12px',
fontWeight: '400',
fontStyle: 'normal',
lineHeight: '17.4px',
padding: '8px 10px',
});
// Layout-relevant textarea styles, copied so overlay, mirror and textarea wrap identically.
const textStyle = ref<Record<string, string>>({});
const textWidth = ref(0);
const gutterPaddingTop = ref('0px');
// Escape HTML utility
const escapeHtml = (value: string): string => {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
const highlightedHtml = computed(() => renderHighlight(modelValue.value, cursor.value));
const BRACKET_PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}' };
const OPENING_BRACKETS = new Set(['(', '[', '{']);
const CLOSING_BRACKETS = new Set([')', ']', '}']);
// Empty lines need content to produce a line box in the mirror.
const measureHtml = computed(() =>
modelValue.value
.split('\n')
.map((line) => `<span class="mrmth-ln-marker"></span>${escapeHtml(line || ' ')}`)
.join('\n')
);
interface Token {
type: string;
value: string;
depth: number;
}
const sharedTextStyle = computed(() => ({
...textStyle.value,
width: textWidth.value ? `${textWidth.value}px` : '100%',
}));
// Tokenize standard input text
const tokenize = (text: string): Token[] => {
const tokens: Token[] = [];
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 gutterStyle = computed(() => ({
fontFamily: textStyle.value.fontFamily,
fontSize: textStyle.value.fontSize,
lineHeight: textStyle.value.lineHeight,
paddingTop: gutterPaddingTop.value,
transform: `translateY(${-scrollTop.value}px)`,
}));
const bracketStack: string[] = [];
const depthByType: Record<string, number> = { '(': 0, '[': 0, '{': 0 };
let m;
const updateLineNumbers = () => {
const measureEl = measureElRef.value;
if (!measureEl) return;
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] || 0;
depthByType[value] = depth + 1;
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) - 1;
depthByType[lastOpen] = depth;
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 });
const lineHeightPx = parseFloat(textStyle.value.lineHeight);
const markers = measureEl.querySelectorAll<HTMLElement>('.mrmth-ln-marker');
const contentBottom = measureEl.clientHeight - parseFloat(getComputedStyle(measureEl).paddingBottom);
const lines: string[] = [];
for (let i = 0; i < markers.length; i++) {
const y0 = markers[i].offsetTop;
const y1 = i + 1 < markers.length ? markers[i + 1].offsetTop : contentBottom;
lines.push(String(i + 1));
const visualLines = Math.max(1, Math.round((y1 - y0) / lineHeightPx));
for (let j = 1; j < visualLines; j++) lines.push('');
}
return tokens;
gutterText.value = lines.join('\n');
};
// Generate syntax highlighted HTML
const highlightedHtml = computed(() => {
return tokenize(modelValue.value)
.map((t) => {
if (t.type === 'text') return escapeHtml(t.value);
if (t.type === 'bracket') {
const d = t.depth % 6;
return `<span class="mrmth-token-bracket mrmth-bracket-${d}">${escapeHtml(t.value)}</span>`;
}
return `<span class="mrmth-token-${t.type}">${escapeHtml(t.value)}</span>`;
})
.join('');
});
// Generate HTML for mirror measurement layer
const measureHtml = computed(() => {
const lines = modelValue.value.split('\n');
return lines
.map((line, idx) => {
const content = line === '' ? ' ' : line;
return `<span class="mrmth-ln-marker" data-ln="${idx}"></span>${escapeHtml(content)}`;
})
.join('\n');
});
// Line height measurement utility
const getLineHeightPx = (el: HTMLTextAreaElement): number => {
const style = window.getComputedStyle(el);
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;
};
// Stable delta offset tracker for smoothing
const prevOffset = ref<number>(0);
const stableCount = ref<number>(0);
const locked = ref<boolean>(false);
// Core alignment and line number updates
const refresh = () => {
const textarea = textareaRef.value;
const measureEl = measureElRef.value;
const gutterContent = gutterContentRef.value;
if (!textarea || !measureEl || !gutterContent) return;
if (!textarea) return;
textareaWidth.value = textarea.clientWidth;
// Retrieve actual styles from textarea
const cs = window.getComputedStyle(textarea);
textareaStyles.value = {
textStyle.value = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
fontStyle: cs.fontStyle,
letterSpacing: cs.letterSpacing,
lineHeight: cs.lineHeight,
padding: cs.padding,
tabSize: cs.tabSize,
padding: `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`,
borderWidth: `${cs.borderTopWidth} ${cs.borderRightWidth} ${cs.borderBottomWidth} ${cs.borderLeftWidth}`,
};
// Same border-box width as the textarea minus its scrollbar.
textWidth.value = textarea.clientWidth + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
gutterPaddingTop.value = `${parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth)}px`;
const rawLines = modelValue.value.split('\n');
const lineCount = Math.max(1, rawLines.length);
const totalHeight = measureEl.offsetHeight;
const parsedLH = parseFloat(cs.lineHeight);
const lhPx = Number.isFinite(parsedLH) && parsedLH > 0 ? parsedLH : getLineHeightPx(textarea);
const newGutterLines: string[] = [];
for (let i = 0; i < lineCount; i++) {
const marker = measureEl.querySelector(`[data-ln="${i}"]`) as HTMLElement | null;
const nextMarker = i < lineCount - 1 ? measureEl.querySelector(`[data-ln="${i + 1}"]`) as HTMLElement | null : null;
const y0 = marker ? marker.offsetTop : i * lhPx;
const y1 = nextMarker ? nextMarker.offsetTop : totalHeight;
const visualLines = Math.max(1, Math.round((y1 - y0) / lhPx));
newGutterLines.push(String(i + 1));
for (let j = 1; j < visualLines; j++) {
newGutterLines.push(' ');
}
}
gutterLines.value = newGutterLines;
// Perform baseline and transform alignment on next tick
nextTick(() => {
const firstMarker = measureEl.querySelector(`[data-ln="0"]`) as HTMLElement | null;
const gutterFirstLine = gutterContent.firstElementChild as HTMLElement | null;
if (!firstMarker || !gutterFirstLine) return;
// ensure probes exist
let measureProbe = firstMarker.querySelector('.mrmth-baseline-probe') as HTMLElement | null;
if (!measureProbe) {
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.querySelector('.mrmth-baseline-probe') as HTMLElement | null;
if (!gutterProbe) {
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);
}
let baselineDelta = 0;
try {
const mProbeRect = measureProbe.getBoundingClientRect();
const gProbeRect = gutterProbe.getBoundingClientRect();
const mRect = measureEl.getBoundingClientRect();
const gRect = gutterContent.getBoundingClientRect();
const mOffset = mProbeRect.top - mRect.top;
const gOffset = gProbeRect.top - gRect.top;
baselineDelta = mOffset - gOffset;
} catch (e) {
baselineDelta = firstMarker.offsetTop + measureProbe.offsetTop - (gutterFirstLine.offsetTop + gutterProbe.offsetTop);
}
if (!Number.isFinite(baselineDelta)) baselineDelta = prevOffset.value;
const maxJumpPerFrame = 20;
if (locked.value) {
baselineDelta = prevOffset.value;
} else {
const jump = baselineDelta - prevOffset.value;
if (Math.abs(jump) > maxJumpPerFrame) {
baselineDelta = prevOffset.value + Math.sign(jump) * maxJumpPerFrame;
}
if (Math.abs(baselineDelta) > 200) {
baselineDelta = prevOffset.value;
}
const postJump = baselineDelta - prevOffset.value;
if (Math.abs(postJump) <= 1) stableCount.value++;
else stableCount.value = 0;
if (stableCount.value >= 3) locked.value = true;
}
prevOffset.value = baselineDelta;
// Apply translation to gutter
gutterContent.style.transform = `translateY(${baselineDelta - scrollTop.value}px)`;
const parent = wrapperRef.value?.parentElement;
if (parent && parent.clientHeight > 0) {
editorHeight.value = '100%';
}
});
// Measure once the mirror has re-rendered with the new text and styles.
nextTick(updateLineNumbers);
};
// Event handlers
const onInput = () => {
refresh();
const syncScroll = () => {
const textarea = textareaRef.value;
const syntaxLayer = syntaxLayerRef.value;
if (!textarea || !syntaxLayer) return;
syntaxLayer.scrollTop = textarea.scrollTop;
syntaxLayer.scrollLeft = textarea.scrollLeft;
};
const onScroll = () => {
const textarea = textareaRef.value;
const syntaxLayer = syntaxLayerRef.value;
const gutterContent = gutterContentRef.value;
if (!textarea) return;
scrollTop.value = textarea.scrollTop;
scrollLeft.value = textarea.scrollLeft;
if (syntaxLayer) {
syntaxLayer.scrollTop = textarea.scrollTop;
syntaxLayer.scrollLeft = textarea.scrollLeft;
}
if (gutterContent) {
gutterContent.style.transform = `translateY(${prevOffset.value - textarea.scrollTop}px)`;
}
scrollTop.value = textareaRef.value?.scrollTop ?? 0;
syncScroll();
};
const onKeyDown = (e: KeyboardEvent) => {
if (!OPENING_BRACKETS.has(e.key)) return;
const textarea = textareaRef.value;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (start !== end) return;
const nextChar = modelValue.value[start] ?? '';
const closing = BRACKET_PAIRS[e.key];
if (/[a-zA-Z0-9_]/.test(nextChar) || nextChar === closing) return;
e.preventDefault();
modelValue.value = `${modelValue.value.slice(0, start)}${e.key}${closing}${modelValue.value.slice(start)}`;
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
refresh();
});
if (textareaRef.value && handleEditingKey(textareaRef.value, e)) e.preventDefault();
};
// Computed styles
const sharedTextStyle = computed(() => {
return {
fontFamily: textareaStyles.value.fontFamily,
fontSize: textareaStyles.value.fontSize,
fontWeight: textareaStyles.value.fontWeight,
fontStyle: textareaStyles.value.fontStyle,
lineHeight: textareaStyles.value.lineHeight,
padding: textareaStyles.value.padding,
width: textareaWidth.value ? `${textareaWidth.value}px` : '100%',
boxSizing: 'border-box' as const,
};
});
const onSelectionChange = () => {
const textarea = textareaRef.value;
if (!textarea || document.activeElement !== textarea) return;
cursor.value = textarea.selectionStart === textarea.selectionEnd ? textarea.selectionStart : -2;
};
const gutterStyle = computed(() => {
return {
fontFamily: textareaStyles.value.fontFamily,
fontSize: textareaStyles.value.fontSize,
fontWeight: textareaStyles.value.fontWeight,
fontStyle: textareaStyles.value.fontStyle,
lineHeight: textareaStyles.value.lineHeight,
padding: textareaStyles.value.padding,
boxSizing: 'border-box' as const,
};
});
const editorStyle = computed(() => {
return {
height: editorHeight.value,
};
});
// Setup observers
let resizeObserver: ResizeObserver | null = null;
let autocompleteController: { destroy: () => void } | null = null;
onMounted(() => {
const textarea = textareaRef.value!;
// Autocomplete first: its keydown handler must run before the editing keys.
autocompleteController = attachAutocomplete(textarea);
textarea.addEventListener('keydown', onKeyDown);
document.addEventListener('selectionchange', onSelectionChange);
resizeObserver = new ResizeObserver(refresh);
resizeObserver.observe(editorContainerRef.value!);
refresh();
// Ensure refresh also settles after parent and layout rendering
setTimeout(refresh, 50);
setTimeout(refresh, 200);
if (window.ResizeObserver && editorContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
refresh();
});
resizeObserver.observe(editorContainerRef.value);
}
nextTick(() => {
if (textareaRef.value && editorContainerRef.value) {
autocompleteController = attachAutocomplete(textareaRef.value, editorContainerRef.value);
}
});
});
onBeforeUnmount(() => {
autocompleteController?.destroy();
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver?.disconnect();
textareaRef.value?.removeEventListener('keydown', onKeyDown);
document.removeEventListener('selectionchange', onSelectionChange);
});
watch(modelValue, () => {
refresh();
});
watch(modelValue, refresh);
// Replacing the overlay's content can reset its scroll position.
watch(highlightedHtml, syncScroll, { flush: 'post' });
</script>
<style scoped>
@@ -486,23 +217,11 @@ watch(modelValue, () => {
}
.mrmth-gutter-content {
padding: 0 8px;
white-space: pre;
will-change: transform;
}
.mrmth-gutter-line {
padding: 0 8px;
display: block;
}
.mrmth-baseline-probe {
display: inline-block;
width: 0;
height: 0;
line-height: 0;
overflow: hidden;
}
.mrmth-editor-container {
position: absolute;
inset: 0 0 0 44px;
@@ -512,16 +231,23 @@ watch(modelValue, () => {
background: transparent;
}
.mrmth-measure,
.mrmth-syntax-layer {
margin: 0 !important;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
box-sizing: border-box;
border-style: solid;
border-color: transparent;
}
.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;
height: auto;
margin: 0 !important;
}
.mrmth-syntax-layer {
@@ -529,30 +255,31 @@ watch(modelValue, () => {
inset: 0;
z-index: 3;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
overflow: hidden;
opacity: 1;
margin: 0 !important;
color: #ecf0f1;
}
/* Extra scroll room so the last line can scroll as far as in the textarea */
.mrmth-syntax-layer::after {
content: "";
display: block;
height: 1em;
}
.mrmth-script-textarea {
position: absolute;
inset: 0;
z-index: 2;
line-height: 1.45;
border: 0 !important;
outline: none !important;
resize: none !important;
color: transparent !important;
caret-color: #ffffff !important;
background: transparent !important;
mix-blend-mode: normal;
-webkit-text-fill-color: transparent;
overflow-y: auto;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
margin: 0 !important;
}
@@ -573,4 +300,15 @@ watch(modelValue, () => {
:deep(.mrmth-bracket-3) { color: #98fb98; }
:deep(.mrmth-bracket-4) { color: #ff6347; }
:deep(.mrmth-bracket-5) { color: #ffa500; }
:deep(.mrmth-bracket-unmatched) {
color: #ff4d4d;
text-decoration: underline wavy #ff4d4d;
}
:deep(.mrmth-bracket-active) {
outline: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
}
</style>
+488 -290
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,15 +1,15 @@
// Generated automatically by compile.ps1. Do not edit.
export const KEYWORDS = new Set([
"break", "continue", "else", "for", "if", "in", "none", "null", "return", "while"
"break", "continue", "else", "for", "if", "in", "none", "None", "null", "NULL", "return", "while"
]);
export const CONSTANTS = new Set([
"e", "pi"
"e", "E", "pi", "PI"
]);
export const FUNCTIONS = new Set([
"abs", "acos", "acosh", "add_key", "added_keys", "all", "allclose", "angle", "any", "append", "argmax", "argmin", "argsort", "as_nested", "as_nested_tensor", "asin", "asinh", "atan", "atan2", "atanh", "band", "batch_shuffle", "bitcount", "bitwise_and", "bitwise_not", "bitwise_or", "bitwise_xor", "blur", "bnot", "bor", "botk", "botk_ind", "botk_indices", "brown", "brown_noise", "bxor", "cat", "cbrt", "ceil", "cellular", "cellular_noise", "cielab_to_rgb", "clamp", "cnt", "concat", "concatenate", "conv", "convolution", "coordinates", "coords", "corr", "correlation", "cos", "cosh", "cosine_similarity", "cossim", "count", "cov", "crop", "cross", "cross_entropy", "cubic", "cubic_ease", "cumprod", "cumsum", "degrees", "det", "diag", "diagonal_matrix", "dilate", "disable_tracking", "dist", "distance", "domain_warp", "domain_warp_noise", "dot", "edge", "elastic", "elastic_ease", "enable_tracking", "endswith", "entropy", "erf", "erfc", "erfinv", "erode", "exp", "expm1", "eye", "ezconv", "ezconvolution", "fft", "find", "flatten", "flip", "float", "floor", "flow_ang", "flow_angle", "flow_apply", "flow_mag", "flow_magnitude", "flow_to_image", "fract", "gamma", "gather", "gaussian", "gelu", "get_added_keys", "get_changed_keys", "get_removed_keys", "get_unchanged_keys", "get_value", "group_norm", "hist", "histogram", "hsv_to_rgb", "huber_loss", "hypot", "changed_keys", "ifft", "int", "int_to_rgb", "interpolate_area", "interpolate_linear", "interpolate_nearest", "interpolate_nearest_exact", "inv", "irfft", "isclose", "isfinite", "isinf", "isnan", "join", "keys", "leaky_relu", "length", "lerp", "linspace", "ln", "log", "log1p", "log2", "logaddexp", "logical_and", "logical_not", "logical_or", "logspace", "logsumexp", "lower", "mae", "map", "matmul", "matrix_rank", "mean", "median", "mode", "moment", "morph_close", "morph_open", "motion_mask", "mse", "nan_to_num", "noise", "nonzero", "norm", "normalize", "now", "nvl", "oklab_to_rgb", "one_hot", "ones", "outer", "overlay", "pad", "percentile", "perlin", "perlin_noise", "perm", "permute", "pink", "pink_noise", "pinv", "plasma", "plasma_noise", "popcnt", "popcount", "pow", "prcnt", "print", "print_shape", "prod", "pshp", "psnr", "quantile", "quartil", "quartile", "radians", "rand", "randb", "randbeta", "randc", "rande", "randg", "randgumbel", "randchi2", "randl", "randln", "randn", "random_bernoulli", "random_beta", "random_cauchy", "random_exponential", "random_gamma", "random_gumbel", "random_chi2", "random_laplace", "random_log_normal", "random_normal", "random_poisson", "random_studentt", "random_uniform", "random_weibull", "randp", "randt", "randu", "randw", "range", "reciprocal", "relu", "remap", "remove_key", "removed_keys", "repeat", "replace", "reset_tracking", "reshape", "rfft", "rgb_to_cielab", "rgb_to_hsv", "rgb_to_int", "rgb_to_oklab", "ridged", "ridged_noise", "rife", "rm_key", "roll", "round", "rshp", "scatter", "scatter_add", "select", "shape", "shuffle", "sigm", "sign", "silu", "sin", "sinc", "sine", "sine_ease", "singular_value_decomposition", "sinh", "slerp", "smax", "smin", "smootherstep", "smoothstep", "snorm", "softmax", "softmin", "softplus", "softsign", "solve", "sort", "split", "sqrt", "squeeze", "stack", "stack_clear", "stack_get", "stack_has", "stack_pop", "stack_push", "startswith", "std", "step", "substr", "substring", "sum", "svd", "swap", "swapaxes", "tan", "tanh", "tensor", "text_image", "timestamp", "tmax", "tmin", "tnorm", "topk", "topk_ind", "topk_indices", "trace", "track_off", "track_on", "track_reset", "transpose", "trim", "turbulence", "unchanged_keys", "unique", "unsqueeze", "upper", "var", "voronoi", "voronoi_noise", "weighted_mean", "where", "worley", "zeros"
"abs", "acos", "acosh", "add_key", "added_keys", "all", "allclose", "angle", "any", "append", "argmax", "argmin", "argsort", "as_nested", "as_nested_tensor", "asin", "asinh", "atan", "atan2", "atanh", "band", "batch_shuffle", "bitcount", "bitwise_and", "bitwise_not", "bitwise_or", "bitwise_xor", "blur", "bnot", "bor", "botk", "botk_ind", "botk_indices", "brown", "brown_noise", "bxor", "cat", "cbrt", "ceil", "cellular", "cellular_noise", "changed_keys", "cielab_to_rgb", "clamp", "cnt", "concat", "concatenate", "conv", "convolution", "coordinates", "coords", "corr", "correlation", "cos", "cosh", "cosine_similarity", "cossim", "count", "cov", "crop", "cross", "cross_entropy", "cubic", "cubic_ease", "cumprod", "cumsum", "degrees", "det", "diag", "diagonal_matrix", "dilate", "disable_tracking", "dist", "distance", "domain_warp", "domain_warp_noise", "dot", "edge", "elastic", "elastic_ease", "enable_tracking", "endswith", "entropy", "erf", "erfc", "erfinv", "erode", "exp", "expm1", "eye", "ezconv", "ezconvolution", "fft", "find", "flatten", "flip", "float", "floor", "flow_ang", "flow_angle", "flow_apply", "flow_mag", "flow_magnitude", "flow_to_image", "fract", "gamma", "gather", "gaussian", "gelu", "get_added_keys", "get_changed_keys", "get_removed_keys", "get_unchanged_keys", "get_value", "group_norm", "hist", "histogram", "hsv_to_rgb", "huber_loss", "hypot", "ifft", "int", "int_to_rgb", "interpolate_area", "interpolate_linear", "interpolate_nearest", "interpolate_nearest_exact", "inv", "irfft", "isclose", "isfinite", "isinf", "isnan", "join", "keys", "leaky_relu", "length", "lerp", "linspace", "ln", "log", "log1p", "log2", "logaddexp", "logical_and", "logical_not", "logical_or", "logspace", "logsumexp", "lower", "mae", "map", "matmul", "matrix_rank", "mean", "median", "mode", "moment", "morph_close", "morph_open", "motion_mask", "mse", "nan_to_num", "noise", "nonzero", "norm", "normalize", "now", "nvl", "oklab_to_rgb", "one_hot", "ones", "outer", "overlay", "pad", "percentile", "perlin", "perlin_noise", "perm", "permute", "pink", "pink_noise", "pinv", "plasma", "plasma_noise", "popcnt", "popcount", "pow", "prcnt", "print", "print_shape", "prod", "pshp", "psnr", "quantile", "quartil", "quartile", "radians", "rand", "randb", "randbeta", "randc", "randchi2", "rande", "randg", "randgumbel", "randl", "randln", "randn", "random_bernoulli", "random_beta", "random_cauchy", "random_chi2", "random_exponential", "random_gamma", "random_gumbel", "random_laplace", "random_log_normal", "random_normal", "random_poisson", "random_studentt", "random_uniform", "random_weibull", "randp", "randt", "randu", "randw", "range", "reciprocal", "relu", "remap", "remove_key", "removed_keys", "repeat", "replace", "reset_tracking", "reshape", "rfft", "rgb_to_cielab", "rgb_to_hsv", "rgb_to_int", "rgb_to_oklab", "ridged", "ridged_noise", "rife", "rm_key", "roll", "round", "rshp", "scatter", "scatter_add", "select", "shape", "shuffle", "sigm", "sign", "silu", "sin", "sinc", "sine", "sine_ease", "singular_value_decomposition", "sinh", "slerp", "smax", "smin", "smootherstep", "smoothstep", "snorm", "softmax", "softmin", "softplus", "softsign", "solve", "sort", "split", "sqrt", "squeeze", "stack", "stack_clear", "stack_get", "stack_has", "stack_pop", "stack_push", "startswith", "std", "step", "substr", "substring", "sum", "svd", "swap", "swapaxes", "tan", "tanh", "tensor", "text_image", "timestamp", "tmax", "tmin", "tnorm", "topk", "topk_ind", "topk_indices", "trace", "track_off", "track_on", "track_reset", "transpose", "trim", "turbulence", "unchanged_keys", "unique", "unsqueeze", "upper", "var", "voronoi", "voronoi_noise", "weighted_mean", "where", "worley", "zeros"
]);
export const FUNCTION_META = {
+215 -510
View File
@@ -1,17 +1,11 @@
import { app } from "/scripts/app.js";
import { KEYWORDS, CONSTANTS, FUNCTIONS } from "./inbuilt_symbols.js";
import { attachAutocomplete } from "./autocomplete.js";
import { attachAutocomplete, escapeHtml, handleEditingKey, renderHighlight } from "./autocomplete.js";
const STYLE_ID = "mrmth-script-line-numbers-unified";
function escapeHtml(value) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// `widget.element` of every mrmth_ScriptInput script widget; only these get enhanced.
const scriptTextareas = new WeakSet();
const editors = new WeakMap();
if (!window.__mrmthScriptInputUnifiedInit) {
window.__mrmthScriptInputUnifiedInit = true;
@@ -33,72 +27,49 @@ if (!window.__mrmthScriptInputUnifiedInit) {
background: rgba(0,0,0,0.18);
}
.mrmth-editor:not(.mrmth-editor-v2) .mrmth-gutter {
padding: 8px 10px;
.mrmth-gutter {
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 {
padding: 0 6px;
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:not(.mrmth-editor-v2) .mrmth-editor-container {
position: relative;
flex: 1;
min-width: 0;
min-height: 170px;
overflow: hidden;
background: transparent;
}
.mrmth-editor:not(.mrmth-editor-v2) .mrmth-editor-container {
position: relative;
flex: 1;
min-width: 0;
min-height: 170px;
overflow: hidden;
background: transparent; /* ⬅️ DŮLEŽITÉ */
}
.mrmth-editor.mrmth-editor-v2 {
position: relative;
display: block;
width: 100%;
width: 100%;
height: 100%;
min-height: 0;
min-height: 0;
box-sizing: border-box;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 10px;
overflow: hidden;
background: rgba(0,0,0,0.18);
border: 1px solid rgba(255,255,255,0.14);
border-radius: 10px;
overflow: hidden;
background: rgba(0,0,0,0.18);
}
.mrmth-editor-v2 .mrmth-gutter {
position: absolute;
inset: 0 auto 0 0;
width: 44px;
min-height: 0;
}
.mrmth-editor-v2 .mrmth-editor-container {
@@ -106,40 +77,39 @@ if (!window.__mrmthScriptInputUnifiedInit) {
inset: 0 0 0 44px;
min-width: 0;
min-height: 0;
height: auto;
flex: none;
}
.mrmth-syntax-layer,
.mrmth-measure {
margin: 0 !important;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
box-sizing: border-box;
border-style: solid;
border-color: transparent;
}
.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;
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
overflow: hidden;
}
/* Extra scroll room so the last line can scroll as far as in the textarea */
.mrmth-syntax-layer::after {
content: "";
display: block;
height: 1em;
}
.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-syntax-layer span {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
hyphens: none;
}
.mrmth-measure {
@@ -147,22 +117,24 @@ if (!window.__mrmthScriptInputUnifiedInit) {
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-script-textarea {
position: absolute;
inset: 0;
z-index: 2;
line-height: 1.45;
.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; }
color: transparent !important;
caret-color: #ffffff !important;
background: transparent !important;
-webkit-text-fill-color: transparent;
/* Reserve scrollbar space to avoid layout jumps when it appears */
overflow-y: auto;
scrollbar-gutter: stable;
}
.mrmth-token-comment { color: #7f8c8d; }
.mrmth-token-number { color: #f39c12; }
@@ -179,286 +151,88 @@ if (!window.__mrmthScriptInputUnifiedInit) {
.mrmth-bracket-3 { color: #98fb98; }
.mrmth-bracket-4 { color: #ff6347; }
.mrmth-bracket-5 { color: #ffa500; }
.mrmth-bracket-unmatched {
color: #ff4d4d;
text-decoration: underline wavy #ff4d4d;
}
.mrmth-bracket-active {
outline: 1px solid rgba(255,255,255,0.6);
border-radius: 2px;
background: rgba(255,255,255,0.12);
}
`;
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") {
if ((el.textContent || "").trim().toLowerCase() === "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 }));
});
// Copy everything that affects text layout so overlay, mirror and textarea wrap identically.
function syncTextStyle(cs, el) {
el.style.fontFamily = cs.fontFamily;
el.style.fontSize = cs.fontSize;
el.style.fontWeight = cs.fontWeight;
el.style.fontStyle = cs.fontStyle;
el.style.letterSpacing = cs.letterSpacing;
el.style.lineHeight = cs.lineHeight;
el.style.tabSize = cs.tabSize;
el.style.padding = `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`;
el.style.borderWidth = `${cs.borderTopWidth} ${cs.borderRightWidth} ${cs.borderBottomWidth} ${cs.borderLeftWidth}`;
}
function getLineHeightPx(textarea) {
const style = getComputedStyle(textarea);
const lh = parseFloat(style.lineHeight);
if (Number.isFinite(lh) && lh > 0) return lh;
function updateLineNumbers(textarea, gutterContent, measureEl, lineHeightPx) {
const lines = textarea.value.split("\n");
// Empty lines need content to produce a line box in the mirror.
measureEl.innerHTML = lines.map((l) => `<span class="mrmth-ln-marker"></span>${escapeHtml(l || " ")}`).join("\n");
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 += `<span class="mrmth-ln-marker" data-ln="${i}"></span>${escapeHtml(line)}`;
if (i < lines.length - 1) html += "\n";
const markers = measureEl.querySelectorAll(".mrmth-ln-marker");
const contentBottom = measureEl.clientHeight - parseFloat(getComputedStyle(measureEl).paddingBottom);
const gutterLines = [];
for (let i = 0; i < markers.length; i++) {
const y0 = markers[i].offsetTop;
const y1 = i + 1 < markers.length ? markers[i + 1].offsetTop : contentBottom;
gutterLines.push(String(i + 1));
const visualLines = Math.max(1, Math.round((y1 - y0) / lineHeightPx));
for (let j = 1; j < visualLines; j++) gutterLines.push("");
}
return html;
gutterContent.textContent = gutterLines.join("\n");
}
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')) {
// An ancestor with an explicit height, max-height or constrained overflow bounds the editor.
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 => `<div class="mrmth-gutter-line">${escapeHtml(l)}</div>`).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.mrmthBaselineDelta || "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
});
}
// The measured baseline delta already aligns the gutter with the text baseline.
const adjustedBaseline = baselineDelta;
// Apply transform on next frame to avoid layout thrash
gutterContent.dataset.mrmthBaselineDelta = String(baselineDelta);
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";
if (!scriptTextareas.has(textarea)) return;
// The frontend re-mounts widget.element into a fresh container (e.g. after
// subgraph navigation); move the editor along with it.
const existing = editors.get(textarea);
if (existing) {
if (!existing.wrapper.contains(textarea)) {
textarea.replaceWith(existing.wrapper);
existing.container.appendChild(textarea);
requestAnimationFrame(existing.refresh);
}
return;
}
@@ -498,158 +272,153 @@ if (!window.__mrmthScriptInputUnifiedInit) {
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';
// Autocomplete first: its keydown handler must run before the editing keys.
const autocomplete = attachAutocomplete(textarea);
textarea.addEventListener("keydown", (e) => {
if (handleEditingKey(textarea, e)) e.preventDefault();
});
const syncScroll = () => {
gutterContent.style.transform = `translateY(${-textarea.scrollTop}px)`;
syntaxLayer.scrollTop = textarea.scrollTop;
syntaxLayer.scrollLeft = textarea.scrollLeft;
};
const updateHighlight = () => {
const focused = document.activeElement === textarea && textarea.selectionStart === textarea.selectionEnd;
syntaxLayer.innerHTML = renderHighlight(textarea.value, focused ? textarea.selectionStart : -2);
syncScroll();
};
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;
const cs = getComputedStyle(textarea);
syncTextStyle(cs, syntaxLayer);
syncTextStyle(cs, measureEl);
gutterContent.style.fontFamily = cs.fontFamily;
gutterContent.style.fontSize = cs.fontSize;
gutterContent.style.lineHeight = cs.lineHeight;
gutterContent.style.paddingTop = `${parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth)}px`;
// Same border-box width as the textarea minus its scrollbar, so text wraps identically.
const widthPx = textarea.clientWidth + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
measureEl.style.width = `${widthPx}px`;
syntaxLayer.style.width = `${widthPx}px`;
syntaxLayer.style.boxSizing = 'border-box';
updateNumbersWrapped(textarea, gutterContent, measureEl, lineHeightPx);
updateHighlight(textarea, syntaxLayer);
const lineHeightPx = parseFloat(cs.lineHeight);
updateLineNumbers(textarea, gutterContent, measureEl, lineHeightPx);
updateHighlight();
if (isVueWidget) {
wrapper.style.minHeight = '0';
wrapper.style.height = '100%';
editorContainer.style.minHeight = '0';
editorContainer.style.height = '100%';
wrapper.style.minHeight = "0";
wrapper.style.height = "100%";
editorContainer.style.minHeight = "0";
editorContainer.style.height = "100%";
return;
}
// 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;
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);
if (parentHeight !== null) {
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);
}
// If the viewport is the bound, prefer a higher static ancestor so the editor doesn't grow with itself.
if (bounding === document.documentElement) {
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);
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
}
wrapper.style.minHeight = "170px";
// A node enlarged beyond the content height lets the editor fill it; otherwise 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";
}
};
// Force initial measurement again after a short delay to allow different node wrappers to settle
setTimeout(refresh, 50);
setTimeout(refresh, 200);
const onSelectionChange = () => {
if (document.activeElement === textarea) updateHighlight();
};
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;
textarea.addEventListener("scroll", syncScroll);
textarea.addEventListener("blur", updateHighlight);
document.addEventListener("selectionchange", onSelectionChange);
const ro = new ResizeObserver(refresh);
ro.observe(editorContainer);
editors.set(textarea, {
wrapper,
container: editorContainer,
refresh,
destroy() {
ro.disconnect();
autocomplete.destroy();
document.removeEventListener("selectionchange", onSelectionChange);
},
});
// 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";
}
}
}
if (node.comfyClass !== "mrmth_ScriptInput" && node.type !== "mrmth_ScriptInput") return;
const widget = node.widgets?.find(w => w.name === "script");
if (!(widget?.element instanceof HTMLTextAreaElement)) return;
const isVue = app.ui?.settings?.getSettingValue?.("Comfy.VueNodes.Enabled") || document.getElementById("vue-app");
// No Vue component is registered for this type, so Nodes 2.0 mounts the
// DOM widget's own textarea, which is then enhanced like in the classic UI.
if (isVue) widget.type = "scriptinput";
const textarea = widget.element;
scriptTextareas.add(textarea);
if (textarea.isConnected) attachToTextarea(textarea);
// Loading a workflow sets the value without an input event.
const onConfigure = node.onConfigure;
node.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
const editor = editors.get(textarea);
if (editor) requestAnimationFrame(editor.refresh);
return r;
};
const onRemoved = node.onRemoved;
node.onRemoved = function () {
editors.get(textarea)?.destroy();
editors.delete(textarea);
return onRemoved?.apply(this, arguments);
};
},
async setup() {
scan(document);
// Script widgets are mounted into the DOM after nodeCreated; enhance them once they appear.
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);
if (node instanceof HTMLTextAreaElement) attachToTextarea(node);
else node.querySelectorAll("textarea").forEach(attachToTextarea);
}
}
});
@@ -658,67 +427,3 @@ if (!window.__mrmthScriptInputUnifiedInit) {
},
});
}
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 `<span class="mrmth-token-bracket mrmth-bracket-${d}">${escapeHtml(t.value)}</span>`;
}
return `<span class="mrmth-token-${t.type}">${escapeHtml(t.value)}</span>`;
}).join("");
}