diff --git a/more_math/Parser/compile.ps1 b/more_math/Parser/compile.ps1
index e447242..1b27da9 100644
--- a/more_math/Parser/compile.ps1
+++ b/more_math/Parser/compile.ps1
@@ -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 = @{}
diff --git a/more_math/Parser/inbuilt_symbols.py b/more_math/Parser/inbuilt_symbols.py
index 5dda27f..d2137f8 100644
--- a/more_math/Parser/inbuilt_symbols.py
+++ b/more_math/Parser/inbuilt_symbols.py
@@ -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 = {
diff --git a/tests/test_bitwise_fix.py b/tests/test_bitwise_fix.py
new file mode 100644
index 0000000..1067e81
--- /dev/null
+++ b/tests/test_bitwise_fix.py
@@ -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)
diff --git a/web/ScriptTextInput.js b/web/ScriptTextInput.js
deleted file mode 100644
index e69de29..0000000
diff --git a/web/ScriptTextInput.vue b/web/ScriptTextInput.vue
index 9ba470a..c9e33b3 100644
--- a/web/ScriptTextInput.vue
+++ b/web/ScriptTextInput.vue
@@ -2,25 +2,10 @@
@@ -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"
>
@@ -62,390 +45,138 @@
diff --git a/web/autocomplete.js b/web/autocomplete.js
index 067b51a..7b2ac49 100644
--- a/web/autocomplete.js
+++ b/web/autocomplete.js
@@ -7,50 +7,36 @@ function ensureAutocompleteStyles() {
const style = document.createElement("style");
style.id = AUTOCOMPLETE_STYLE_ID;
style.textContent = `
+ .mrmth-autocomplete-layout {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ position: fixed;
+ z-index: 10000;
+ pointer-events: none;
+ }
+ .mrmth-autocomplete-layout[hidden],
+ .mrmth-signature-hint[hidden] {
+ display: none;
+ }
-.mrmth-autocomplete-tooltip {
- display: none; /* Řízeno přes JS */
- min-width: 200px;
- max-width: 320px;
- margin-left: 8px;
- padding: 10px 12px;
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- background: rgba(24, 28, 36, 0.98);
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
- font-family: monospace;
- font-size: 11px;
- color: #b3b9c1;
- line-height: 1.5;
- pointer-events: auto;
-}
-
-.mrmth-autocomplete-layout {
- display: flex !important;
- flex-direction: row !important;
- align-items: flex-start !important;
- position: fixed !important; /* Pozici drží POUZE tento společný obal! */
- z-index: 10000 !important;
- pointer-events: none;
-}
-
-.mrmth-autocomplete {
- min-width: 180px;
- max-height: 220px;
- overflow-y: auto;
- overscroll-behavior: contain;
- pointer-events: auto;
- margin: 0;
- padding: 4px 0;
- list-style: none;
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: 8px;
- background: rgba(18, 20, 24, 0.97);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
- font-family: monospace;
- font-size: 12px;
-}
+ .mrmth-autocomplete {
+ width: 260px;
+ max-height: 220px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ pointer-events: auto;
+ margin: 0;
+ padding: 4px 0;
+ list-style: none;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 8px;
+ background: rgba(18, 20, 24, 0.97);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ font-family: monospace;
+ font-size: 12px;
+ }
.mrmth-autocomplete-item {
display: flex;
@@ -78,10 +64,245 @@ function ensureAutocompleteStyles() {
color: #c39bd3;
font-weight: 600;
}
+
+ .mrmth-autocomplete-item.is-variable .mrmth-ac-name {
+ color: #ecf0f1;
+ }
+
+ .mrmth-autocomplete-tooltip,
+ .mrmth-signature-hint {
+ max-width: 320px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background: rgba(24, 28, 36, 0.98);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ font-family: monospace;
+ font-size: 11px;
+ color: #b3b9c1;
+ line-height: 1.5;
+ }
+
+ .mrmth-autocomplete-tooltip {
+ min-width: 200px;
+ margin-left: 8px;
+ pointer-events: auto;
+ }
+
+ .mrmth-signature-hint {
+ position: fixed;
+ z-index: 10000;
+ pointer-events: none;
+ }
+
+ .mrmth-tt-title,
+ .mrmth-sig-call {
+ color: #c39bd3;
+ font-weight: 600;
+ }
+
+ .mrmth-sig-call .mrmth-sig-active {
+ color: #ffd700;
+ text-decoration: underline;
+ }
`;
document.head.appendChild(style);
}
+export function escapeHtml(value) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+const BRACKET_PAIRS = { "(": ")", "[": "]", "{": "}" };
+const OPENING_BRACKETS = new Set(Object.keys(BRACKET_PAIRS));
+const CLOSING_BRACKETS = new Set(Object.values(BRACKET_PAIRS));
+const QUOTES = new Set(['"', "'"]);
+const INDENT = " ";
+
+// Mirrors the MathExpr.g4 lexer closely enough for highlighting. Unterminated
+// block comments and strings run to the end of the text/line so they show up.
+const TOKEN_PATTERN = /#.*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\.|[^"\\\r\n])*"?|'(?:\\.|[^'\\\r\n])*'?|\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\B\.\d+(?:[eE][+-]?\d+)?|==|!=|>=|<=|<<|>>|->|\+=|-=|\*=|\/=|%=|[+\-*/%^=<>|?:,;()\[\]{}]|[a-zA-Z_][a-zA-Z_0-9]*|\s+|./g;
+
+/**
+ * Splits script text into tokens. Bracket tokens get `depth` (per bracket
+ * type, for rainbow colouring) and `match` (index of the paired bracket token,
+ * or -1 when unmatched).
+ */
+export function tokenize(text) {
+ const tokens = [];
+ const openStack = [];
+ const depthByType = { "(": 0, "[": 0, "{": 0 };
+ TOKEN_PATTERN.lastIndex = 0;
+ let m;
+
+ while ((m = TOKEN_PATTERN.exec(text)) !== null) {
+ const value = m[0];
+ const c = value[0];
+ const token = { type: "text", value, start: m.index, depth: 0, match: -1 };
+
+ if (c === "#" || value.startsWith("/*")) {
+ token.type = "comment";
+ } else if (c === '"' || c === "'") {
+ token.type = "string";
+ } else if (OPENING_BRACKETS.has(c)) {
+ token.type = "bracket";
+ token.depth = depthByType[c]++;
+ openStack.push(tokens.length);
+ } else if (CLOSING_BRACKETS.has(c)) {
+ token.type = "bracket";
+ const openIndex = openStack[openStack.length - 1];
+ const open = tokens[openIndex];
+ if (open && BRACKET_PAIRS[open.value] === c) {
+ openStack.pop();
+ token.depth = --depthByType[open.value];
+ token.match = openIndex;
+ open.match = tokens.length;
+ }
+ } else if (/^(?:[+\-*/%^=<>|?:,;]|!=)/.test(value)) {
+ token.type = "operator";
+ } else if (/^\.?\d/.test(value)) {
+ token.type = "number";
+ } else if (/^[a-zA-Z_]/.test(c)) {
+ if (KEYWORDS.has(value)) token.type = "keyword";
+ else if (CONSTANTS.has(value)) token.type = "constant";
+ else if (FUNCTIONS.has(value)) token.type = "function";
+ else token.type = "variable";
+ }
+
+ tokens.push(token);
+ }
+ return tokens;
+}
+
+function isBlank(token) {
+ return token.type === "text" && !token.value.trim();
+}
+
+/**
+ * Insert text through the browser's editing pipeline so it lands on the native
+ * undo stack (plain `textarea.value = ...` assignments wipe Ctrl+Z history).
+ * An empty `text` deletes the range.
+ */
+export function insertText(textarea, start, end, text) {
+ textarea.focus();
+ textarea.setSelectionRange(start, end);
+ const ok = text ? document.execCommand("insertText", false, text) : document.execCommand("delete");
+ if (!ok) {
+ textarea.setRangeText(text, start, end, "end");
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+}
+
+export function renderHighlight(text, cursor) {
+ const tokens = tokenize(text);
+
+ // Bracket next to the cursor (left side preferred) and its partner.
+ const active = new Set();
+ const isPairedAt = (pos) => (t) => t.type === "bracket" && t.start === pos && t.match !== -1;
+ let nearIndex = tokens.findIndex(isPairedAt(cursor - 1));
+ if (nearIndex === -1) nearIndex = tokens.findIndex(isPairedAt(cursor));
+ if (nearIndex !== -1) {
+ active.add(nearIndex);
+ active.add(tokens[nearIndex].match);
+ }
+
+ return tokens.map((t, i) => {
+ if (t.type === "text") return escapeHtml(t.value);
+ if (t.type === "bracket") {
+ let cls = t.match === -1 ? "mrmth-bracket-unmatched" : `mrmth-bracket-${t.depth % 6}`;
+ if (active.has(i)) cls += " mrmth-bracket-active";
+ return `${escapeHtml(t.value)}`;
+ }
+ return `${escapeHtml(t.value)}`;
+ }).join("");
+}
+
+/**
+ * Editor key behaviour: bracket/quote pairing, Backspace pair delete, auto
+ * indent on Enter, Tab / Shift+Tab indent. Returns true when the key was
+ * handled and the caller should preventDefault. Keys already consumed by the
+ * autocomplete popup arrive with `defaultPrevented` and are ignored.
+ */
+export function handleEditingKey(textarea, e) {
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false;
+ const { selectionStart: start, selectionEnd: end, value } = textarea;
+ const prev = value[start - 1] ?? "";
+ const next = value[end] ?? "";
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1;
+ const key = e.key;
+
+ // Type over an already present closing bracket/quote.
+ if (start === end && next === key && (CLOSING_BRACKETS.has(key) || QUOTES.has(key))) {
+ textarea.setSelectionRange(start + 1, start + 1);
+ return true;
+ }
+
+ if (Object.hasOwn(BRACKET_PAIRS, key) || QUOTES.has(key)) {
+ const closing = BRACKET_PAIRS[key] ?? key;
+ if (start !== end) {
+ insertText(textarea, start, end, key + value.slice(start, end) + closing);
+ textarea.setSelectionRange(start + 1, end + 1);
+ return true;
+ }
+ if (/\w/.test(next) || (QUOTES.has(key) && /\w/.test(prev))) return false;
+ insertText(textarea, start, end, key + closing);
+ textarea.setSelectionRange(start + 1, start + 1);
+ return true;
+ }
+
+ // `}` typed on a whitespace-only line closes one indent level.
+ if (key === "}" && start === end && /^[ \t]*$/.test(value.slice(lineStart, start)) && value.slice(lineStart, start).endsWith(INDENT)) {
+ insertText(textarea, start - INDENT.length, start, "}");
+ return true;
+ }
+
+ if (key === "Backspace" && start === end && start > 0 && (BRACKET_PAIRS[prev] === next || (QUOTES.has(prev) && prev === next))) {
+ insertText(textarea, start - 1, start + 1, "");
+ return true;
+ }
+
+ if (key === "Enter" && !e.shiftKey) {
+ const before = value.slice(lineStart, start);
+ const lead = before.match(/^[ \t]*/)[0];
+ if (before.trimEnd().endsWith("{")) {
+ const inner = lead + INDENT;
+ const text = next === "}" ? `\n${inner}\n${lead}` : `\n${inner}`;
+ insertText(textarea, start, end, text);
+ const caret = start + 1 + inner.length;
+ textarea.setSelectionRange(caret, caret);
+ } else {
+ insertText(textarea, start, end, `\n${lead}`);
+ }
+ return true;
+ }
+
+ if (key === "Tab") {
+ if (!e.shiftKey && start === end) {
+ insertText(textarea, start, end, INDENT);
+ return true;
+ }
+ // Indent / unindent every line touched by the selection.
+ const blockEnd = end > start && value[end - 1] === "\n" ? end - 1 : end;
+ const block = value.slice(lineStart, blockEnd);
+ const lines = block.split("\n");
+ const newLines = e.shiftKey ? lines.map((l) => l.replace(/^(?: {1,4}|\t)/, "")) : lines.map((l) => INDENT + l);
+ const newBlock = newLines.join("\n");
+ if (newBlock === block) return true;
+ insertText(textarea, lineStart, blockEnd, newBlock);
+ const firstDelta = newLines[0].length - lines[0].length;
+ const totalDelta = newBlock.length - block.length;
+ textarea.setSelectionRange(Math.max(lineStart, start + firstDelta), Math.max(lineStart, end + totalDelta));
+ return true;
+ }
+
+ return false;
+}
+
function isCursorInStringOrComment(text, cursor) {
let inString = false;
let stringChar = "";
@@ -108,7 +329,7 @@ function isCursorInStringOrComment(text, cursor) {
i++;
continue;
}
- if (ch === stringChar) inString = false;
+ if (ch === stringChar || ch === "\n") inString = false;
continue;
}
if (ch === "#") {
@@ -126,7 +347,7 @@ function isCursorInStringOrComment(text, cursor) {
}
}
- return inString || inBlockComment;
+ return inString || inLineComment || inBlockComment;
}
function getWordPrefix(text, cursor) {
@@ -150,53 +371,74 @@ function formatArgHint(meta) {
return `(${meta.minArgs}–${meta.maxArgs} args)`;
}
-function buildSuggestions(prefix) {
+// Names defined or used in the script itself; `name(` counts as a function.
+function collectUserSymbols(text, skipStart) {
+ const tokens = tokenize(text);
+ const symbols = new Map();
+ tokens.forEach((t, i) => {
+ if (t.type !== "variable" || t.start === skipStart) return;
+ let j = i + 1;
+ while (tokens[j] && isBlank(tokens[j])) j++;
+ if (tokens[j]?.value === "(") symbols.set(t.value, "function");
+ else if (!symbols.has(t.value)) symbols.set(t.value, "variable");
+ });
+ return symbols;
+}
+
+const KIND_ORDER = { variable: 0, function: 1, constant: 2, keyword: 3 };
+
+function buildSuggestions(prefix, userSymbols = new Map()) {
const lower = prefix.toLowerCase();
const items = [];
+ const matches = (name) => name.toLowerCase().startsWith(lower);
- for (const name of FUNCTIONS) {
- if (!name.toLowerCase().startsWith(lower)) continue;
- const meta = FUNCTION_META[name];
- items.push({
- name,
- kind: "function",
- hint: meta?.snippet || `${name}()`,
- detail: formatArgHint(meta),
- insertText: meta?.snippet || `${name}()`,
- });
+ for (const [name, kind] of userSymbols) {
+ if (matches(name)) items.push({ name, kind, detail: kind === "function" ? "user" : "" });
}
- for (const name of KEYWORDS) {
- if (!name.toLowerCase().startsWith(lower)) continue;
- items.push({
- name,
- kind: "keyword",
- hint: name,
- detail: "",
- insertText: name,
- });
+ for (const name of FUNCTIONS) {
+ if (matches(name)) items.push({ name, kind: "function", detail: formatArgHint(FUNCTION_META[name]) });
}
for (const name of CONSTANTS) {
- if (!name.toLowerCase().startsWith(lower)) continue;
- items.push({
- name,
- kind: "constant",
- hint: name,
- detail: "",
- insertText: name,
- });
+ if (matches(name)) items.push({ name, kind: "constant", detail: "" });
+ }
+ for (const name of KEYWORDS) {
+ if (matches(name)) items.push({ name, kind: "keyword", detail: "" });
}
- items.sort((a, b) => {
- const kindOrder = { function: 0, constant: 1, keyword: 2 };
- const ka = kindOrder[a.kind] ?? 9;
- const kb = kindOrder[b.kind] ?? 9;
- if (ka !== kb) return ka - kb;
- return a.name.localeCompare(b.name);
- });
-
+ items.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name));
return items.slice(0, 40);
}
+// Innermost built-in call whose argument list contains the cursor.
+function findCallContext(text, cursor) {
+ const tokens = tokenize(text.slice(0, cursor));
+ let depth = 0;
+ let argIndex = 0;
+ for (let i = tokens.length - 1; i >= 0; i--) {
+ const { type, value } = tokens[i];
+ if (value === "," && type === "operator") {
+ if (depth === 0) argIndex++;
+ continue;
+ }
+ if (type !== "bracket") continue;
+ if (CLOSING_BRACKETS.has(value)) {
+ depth++;
+ continue;
+ }
+ if (depth > 0) {
+ depth--;
+ continue;
+ }
+ if (value !== "(") return null;
+
+ let j = i - 1;
+ while (j >= 0 && isBlank(tokens[j])) j--;
+ const name = tokens[j]?.type === "function" ? tokens[j].value : null;
+ return name ? { name, argIndex } : null;
+ }
+ return null;
+}
+
function getCaretCoordinates(textarea, position) {
const style = window.getComputedStyle(textarea);
const div = document.createElement("div");
@@ -219,12 +461,11 @@ function getCaretCoordinates(textarea, position) {
div.style[prop] = style[prop];
}
- const text = textarea.value.substring(0, position);
const span = document.createElement("span");
- span.textContent = text;
+ span.textContent = textarea.value.substring(0, position);
div.appendChild(span);
const marker = document.createElement("span");
- marker.textContent = "\u200B";
+ marker.textContent = "";
div.appendChild(marker);
document.body.appendChild(div);
@@ -234,12 +475,11 @@ function getCaretCoordinates(textarea, position) {
return { top, left };
}
-
+// The node canvas may scale the textarea with a CSS transform.
function getTextareaViewportScale(textarea) {
- const rect = textarea.getBoundingClientRect();
const layoutHeight = textarea.offsetHeight;
if (layoutHeight <= 0) return 1;
- return rect.height / layoutHeight;
+ return textarea.getBoundingClientRect().height / layoutHeight;
}
function getViewportLineHeight(textarea, scale) {
@@ -254,7 +494,7 @@ function getCaretScreenPosition(textarea, position) {
const scale = getTextareaViewportScale(textarea);
const x = coords.left - textarea.scrollLeft;
- const y = coords.top - textarea.scrollTop-6;
+ const y = coords.top - textarea.scrollTop - 6;
return {
left: rect.left + x * scale,
@@ -262,174 +502,129 @@ function getCaretScreenPosition(textarea, position) {
scale,
};
}
+
/**
- * Attach identifier autocomplete to a script textarea.
+ * Attach identifier autocomplete and built-in signature hints to a script
+ * textarea. The popups are portaled to document.body. Must be attached before
+ * other keydown handlers so they can skip keys it consumed (`defaultPrevented`).
* @param {HTMLTextAreaElement} textarea
- * @param {HTMLElement} [_containerEl] - unused; dropdown is portaled to document.body
* @returns {{ destroy: () => void, close: () => void }}
*/
-export function attachAutocomplete(textarea, _containerEl) {
+export function attachAutocomplete(textarea) {
ensureAutocompleteStyles();
- // 1. Deklarace prvků nového rozvržení
- let layoutEl = null;
- let listEl = null; // Toto plně nahradí tvůj původní samostatný "dropdown"
- let tooltipEl = null;
+ const layoutEl = document.createElement("div");
+ layoutEl.className = "mrmth-autocomplete-layout";
+ layoutEl.hidden = true;
- // 2. Opravené vytvoření dropdownu, které se spustí ihned
- function createDropdown() {
- layoutEl = document.createElement("div");
- layoutEl.className = "mrmth-autocomplete-layout";
- layoutEl.style.position = "fixed";
- layoutEl.style.zIndex = "10000";
- layoutEl.style.display = "flex";
- layoutEl.hidden = true;
+ const listEl = document.createElement("ul");
+ listEl.className = "mrmth-autocomplete";
+ layoutEl.appendChild(listEl);
- listEl = document.createElement("ul");
- listEl.className = "mrmth-autocomplete";
- listEl.addEventListener("wheel", onDropdownWheel, { passive: false });
- layoutEl.appendChild(listEl);
+ const tooltipEl = document.createElement("div");
+ tooltipEl.className = "mrmth-autocomplete-tooltip";
+ tooltipEl.style.display = "none";
+ layoutEl.appendChild(tooltipEl);
- tooltipEl = document.createElement("div");
- tooltipEl.className = "mrmth-autocomplete-tooltip";
- tooltipEl.style.display = "none";
- layoutEl.appendChild(tooltipEl);
+ const hintEl = document.createElement("div");
+ hintEl.className = "mrmth-signature-hint";
+ hintEl.hidden = true;
- document.body.appendChild(layoutEl);
- }
+ document.body.appendChild(layoutEl);
+ document.body.appendChild(hintEl);
- function updateTooltip(selectedItem) {
- if (!tooltipEl || !layoutEl) return;
+ let items = [];
+ let selectedIndex = 0;
+ let navigated = false;
+ let prefixInfo = null;
+ let open = false;
- const meta = FUNCTION_META[selectedItem?.name] || selectedItem;
+ listEl.addEventListener("wheel", (e) => {
+ e.stopPropagation();
+ const maxScroll = Math.max(0, listEl.scrollHeight - listEl.clientHeight);
+ if ((e.deltaY < 0 && listEl.scrollTop <= 0) || (e.deltaY > 0 && listEl.scrollTop >= maxScroll)) {
+ e.preventDefault();
+ }
+ }, { passive: false });
- if (!meta || !meta.description) {
+ function updateTooltip(item) {
+ const meta = item.kind === "function" ? FUNCTION_META[item.name] : null;
+ if (!meta?.description) {
tooltipEl.style.display = "none";
return;
}
- let argsText = `Arguments: min ${meta.minArgs ?? 0}`;
- if (meta.maxArgs !== undefined && meta.maxArgs !== null) {
- argsText += `, max ${meta.maxArgs}`;
- } else {
- argsText += "+ (variadic)";
- }
+ let argsText = `Arguments: min ${meta.minArgs}`;
+ argsText += meta.maxArgs === null ? "+ (variadic)" : `, max ${meta.maxArgs}`;
tooltipEl.innerHTML = `
- ${selectedItem.name}
- ${meta.description}
+ ${escapeHtml(meta.snippet)}
+ ${escapeHtml(meta.description)}
${argsText}
`;
-
tooltipEl.style.display = "block";
const rect = tooltipEl.getBoundingClientRect();
- if (rect.right > window.innerWidth) {
- layoutEl.style.flexDirection = "row-reverse";
- tooltipEl.style.marginLeft = "0";
- tooltipEl.style.marginRight = "8px";
- } else {
- layoutEl.style.flexDirection = "row";
- tooltipEl.style.marginLeft = "8px";
- tooltipEl.style.marginRight = "0";
- }
+ const flip = rect.right > window.innerWidth;
+ layoutEl.style.flexDirection = flip ? "row-reverse" : "row";
+ tooltipEl.style.marginLeft = flip ? "0" : "8px";
+ tooltipEl.style.marginRight = flip ? "8px" : "0";
}
-
- const onDropdownWheel = (e) => {
- e.stopPropagation();
- const maxScroll = Math.max(0, listEl.scrollHeight - listEl.clientHeight);
- const atTop = listEl.scrollTop <= 0;
- const atBottom = listEl.scrollTop >= maxScroll;
- if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
- e.preventDefault();
- }
- };
-
- createDropdown();
-
-
- let items = [];
- let selectedIndex = 0;
- let prefixInfo = null;
- let open = false;
-
const close = () => {
open = false;
- if (layoutEl) {
- layoutEl.hidden = true;
- layoutEl.style.visibility = "hidden";
- }
- if (listEl) listEl.innerHTML = "";
- if (tooltipEl) tooltipEl.style.display = "none";
+ layoutEl.hidden = true;
+ listEl.innerHTML = "";
+ tooltipEl.style.display = "none";
items = [];
selectedIndex = 0;
prefixInfo = null;
};
const openWith = (nextItems, nextPrefix) => {
- if (!nextItems.length) {
- close();
- return;
- }
items = nextItems;
selectedIndex = 0;
+ navigated = false;
prefixInfo = nextPrefix;
open = true;
- if (layoutEl) {
- layoutEl.hidden = false;
- layoutEl.style.display = "flex"; // ZNOVU ROZSVÍTÍ KRESLENÍ PŘI PSANÍ
- }
+ layoutEl.hidden = false;
+ hideHint();
render();
- positionDropdown();
};
const scrollSelectedIntoView = () => {
- const selected = layoutEl.querySelector(".mrmth-autocomplete-item.is-selected");
+ const selected = listEl.children[selectedIndex];
if (!selected) return;
const itemTop = selected.offsetTop;
const itemBottom = itemTop + selected.offsetHeight;
- const viewTop = listEl.scrollTop;
- const viewBottom = viewTop + listEl.clientHeight;
-
const pad = 2;
- if (itemTop < viewTop + pad) {
+ if (itemTop < listEl.scrollTop + pad) {
listEl.scrollTop = Math.max(0, itemTop - pad);
- } else if (itemBottom > viewBottom - pad) {
+ } else if (itemBottom > listEl.scrollTop + listEl.clientHeight - pad) {
listEl.scrollTop = itemBottom - listEl.clientHeight + pad;
}
};
-
const render = () => {
- listEl.innerHTML = "";
- if (!items || items.length === 0) {
- close();
- return;
- }
-
listEl.innerHTML = "";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = `mrmth-autocomplete-item is-${item.kind}${index === selectedIndex ? " is-selected" : ""}`;
- li.dataset.index = String(index);
- const left = document.createElement("span");
- left.className = "mrmth-ac-name";
- left.textContent = item.name;
+ const name = document.createElement("span");
+ name.className = "mrmth-ac-name";
+ name.textContent = item.name;
- const right = document.createElement("span");
- right.className = "mrmth-ac-hint";
- right.textContent = item.detail || item.hint;
+ const detail = document.createElement("span");
+ detail.className = "mrmth-ac-hint";
+ detail.textContent = item.detail;
const kind = document.createElement("span");
kind.className = "mrmth-ac-kind";
kind.textContent = item.kind;
- li.appendChild(left);
- li.appendChild(right);
- li.appendChild(kind);
+ li.append(name, detail, kind);
li.addEventListener("mousedown", (e) => {
e.preventDefault();
@@ -439,9 +634,7 @@ export function attachAutocomplete(textarea, _containerEl) {
li.addEventListener("mouseenter", () => {
selectedIndex = index;
- listEl.querySelectorAll(".mrmth-autocomplete-item").forEach((el, i) => {
- el.classList.toggle("is-selected", i === index);
- });
+ for (const el of listEl.children) el.classList.toggle("is-selected", el === li);
updateTooltip(item);
});
@@ -449,28 +642,19 @@ export function attachAutocomplete(textarea, _containerEl) {
});
scrollSelectedIntoView();
-
- if (items[selectedIndex]) {
- updateTooltip(items[selectedIndex]);
- } else {
- tooltipEl.style.display = "none";
- }
-
- if (open) positionDropdown();
+ updateTooltip(items[selectedIndex]);
+ positionDropdown();
};
const positionDropdown = () => {
- if (!prefixInfo || !layoutEl || !listEl) return; // Změna kontroly prvků
-
- const cursorPos = textarea.selectionStart;
- const caret = getCaretScreenPosition(textarea, cursorPos);
+ const caret = getCaretScreenPosition(textarea, textarea.selectionStart);
const textareaRect = textarea.getBoundingClientRect();
- const scale = caret.scale ?? getTextareaViewportScale(textarea);
- const lineHeight = getViewportLineHeight(textarea, scale);
+ const lineHeight = getViewportLineHeight(textarea, caret.scale);
const gap = 6;
const viewportPad = 8;
const preferredMax = 220;
const minListHeight = 48;
+ const dropdownWidth = 260;
const lineTop = caret.top;
const lineBottom = lineTop + lineHeight;
@@ -489,13 +673,11 @@ export function attachAutocomplete(textarea, _containerEl) {
let maxHeight;
let top;
- let left;
- const dropdownWidthTarget = Math.max(180, textareaRect.width);
+ let left = caret.left;
if (placement === "below") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceBelow));
top = lineBottom + gap;
- left = caret.left;
} else if (placement === "above") {
maxHeight = Math.min(preferredMax, Math.max(minListHeight, spaceAbove));
top = lineTop - gap - maxHeight;
@@ -503,141 +685,160 @@ export function attachAutocomplete(textarea, _containerEl) {
maxHeight = Math.max(minListHeight, lineTop - gap - viewportPad);
top = lineTop - gap - maxHeight;
}
- left = caret.left;
} else {
maxHeight = Math.min(preferredMax, window.innerHeight - viewportPad * 2);
top = Math.max(viewportPad, Math.min(lineTop, window.innerHeight - maxHeight - viewportPad));
left = textareaRect.right + gap;
}
- listEl.style.width = `260px`;
-
listEl.style.maxHeight = `${maxHeight}px`;
layoutEl.style.top = `${top}px`;
-
- layoutEl.style.visibility = "hidden";
- layoutEl.style.left = "0px";
-
- const dropdownWidth = 260;
- left = Math.max(viewportPad, Math.min(left, window.innerWidth - dropdownWidth - viewportPad));
-
- layoutEl.style.left = `${left}px`;
- layoutEl.style.visibility = "";
+ layoutEl.style.left = `${Math.max(viewportPad, Math.min(left, window.innerWidth - dropdownWidth - viewportPad))}px`;
};
const applySelected = () => {
- if (selectedIndex < 0 || selectedIndex >= items.length) return;
const item = items[selectedIndex];
- const text = textarea.value;
- const before = text.slice(0, prefixInfo.start);
- const after = text.slice(textarea.selectionStart);
+ if (!item) return;
+ const end = textarea.selectionStart;
+ const isCall = item.kind === "function";
+ const hasParen = textarea.value[end] === "(";
- textarea.value = before + item.insertText + after;
- textarea.selectionStart = textarea.selectionEnd = before.length + item.insertText.length;
- textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ let caret = prefixInfo.start + item.name.length;
+ if (isCall && hasParen) {
+ insertText(textarea, prefixInfo.start, end, item.name);
+ caret += 1;
+ } else if (isCall) {
+ insertText(textarea, prefixInfo.start, end, `${item.name}()`);
+ caret += 1;
+ } else {
+ insertText(textarea, prefixInfo.start, end, item.name);
+ }
+ textarea.setSelectionRange(caret, caret);
close();
+ updateHint();
};
- const maybeOpen = (force = false) => {
+ const maybeOpen = (force) => {
+ const text = textarea.value;
const cursor = textarea.selectionStart;
- if (cursor !== textarea.selectionEnd) {
- close();
- return;
- }
- if (isCursorInStringOrComment(textarea.value, cursor)) {
+ if (cursor !== textarea.selectionEnd || isCursorInStringOrComment(text, cursor)) {
close();
return;
}
- const info = getWordPrefix(textarea.value, cursor);
- if (!info || (!force && info.word.length < 1)) {
+ const info = getWordPrefix(text, cursor);
+ if (!info || (!force && !open && info.word.length < 2)) {
close();
return;
}
- const suggestions = buildSuggestions(info.word);
- if (!suggestions.length) {
+ const suggestions = buildSuggestions(info.word, collectUserSymbols(text, info.start));
+ // Nothing to offer once the word is already complete.
+ if (!suggestions.length || (!force && suggestions.length === 1 && suggestions[0].name === info.word)) {
close();
return;
}
openWith(suggestions, info);
};
- const onInput = () => {
- if (!open) {
- const info = getWordPrefix(textarea.value, textarea.selectionStart);
- if (info && info.word.length >= 2) {
- maybeOpen(false);
- }
+ function hideHint() {
+ hintEl.hidden = true;
+ }
+
+ function updateHint() {
+ const cursor = textarea.selectionStart;
+ if (open || document.activeElement !== textarea || cursor !== textarea.selectionEnd) {
+ hideHint();
return;
}
+ const ctx = findCallContext(textarea.value, cursor);
+ const meta = ctx && FUNCTION_META[ctx.name];
+ if (!meta) {
+ hideHint();
+ return;
+ }
+
+ const argNames = (meta.snippet.match(/\((.*)\)$/)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
+ const variadicAt = argNames.indexOf("...");
+ let active = ctx.argIndex;
+ if (variadicAt !== -1 && active >= variadicAt) active = variadicAt;
+ const args = argNames
+ .map((a, i) => (i === active ? `${escapeHtml(a)}` : escapeHtml(a)))
+ .join(", ");
+
+ hintEl.innerHTML = `
+ ${escapeHtml(ctx.name)}(${args})
+ ${meta.description ? `${escapeHtml(meta.description)}
` : ""}
+ `;
+ hintEl.hidden = false;
+
+ const caret = getCaretScreenPosition(textarea, cursor);
+ const lineHeight = getViewportLineHeight(textarea, caret.scale);
+ const hintRect = hintEl.getBoundingClientRect();
+ const viewportPad = 8;
+ let top = caret.top - hintRect.height - 4;
+ if (top < viewportPad) top = caret.top + lineHeight + 6;
+ hintEl.style.top = `${top}px`;
+ hintEl.style.left = `${Math.max(viewportPad, Math.min(caret.left, window.innerWidth - hintRect.width - viewportPad))}px`;
+ }
+
+ const onInput = () => {
maybeOpen(false);
+ updateHint();
};
const onKeyDown = (e) => {
if (e.ctrlKey && e.key === " ") {
e.preventDefault();
- if (typeof maybeOpen === "function") {
- maybeOpen(true);
- } else {
- const text = textarea.value;
- const cursor = textarea.selectionStart;
- const prefix = getWordPrefix(text, cursor);
- if (prefix) {
- const suggestions = buildSuggestions(prefix.word);
- if (suggestions.length > 0) openWith(suggestions, prefix);
- }
- }
+ maybeOpen(true);
return;
}
- if (!open) return;
+ if (!open) {
+ if (e.key === "Escape") hideHint();
+ return;
+ }
- if (e.key === "ArrowDown") {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
- selectedIndex = (selectedIndex + 1) % items.length;
+ const step = e.key === "ArrowDown" ? 1 : -1;
+ selectedIndex = (selectedIndex + step + items.length) % items.length;
+ navigated = true;
render();
return;
}
- if (e.key === "ArrowUp") {
- e.preventDefault();
- selectedIndex = (selectedIndex - 1 + items.length) % items.length;
- render();
- return;
- }
- if (e.key === "Enter" || e.key === "Tab") {
+ // Enter only accepts after the list was navigated, otherwise it would
+ // swallow newlines after words like `return` that happen to match.
+ if (e.key === "Tab" || (e.key === "Enter" && navigated)) {
e.preventDefault();
applySelected();
return;
}
- if (e.key === "Escape") {
- e.preventDefault();
+ if (e.key === "Enter" || e.key === "Escape") {
+ if (e.key === "Escape") e.preventDefault();
close();
}
};
+ const onSelectionChange = () => {
+ if (document.activeElement === textarea) updateHint();
+ };
+
const onBlur = () => {
setTimeout(close, 120);
+ hideHint();
};
const onReposition = () => {
if (open) positionDropdown();
+ if (!hintEl.hidden) updateHint();
};
- textarea.addEventListener("input", typeof onInput === "function" ? onInput : () => {
- const text = textarea.value;
- const cursor = textarea.selectionStart;
- const prefix = getWordPrefix(text, cursor);
- if (!prefix) { close(); return; }
- const suggestions = buildSuggestions(prefix.word);
- if (suggestions.length > 0) openWith(suggestions, prefix);
- else close();
- });
-
+ textarea.addEventListener("input", onInput);
textarea.addEventListener("keydown", onKeyDown);
textarea.addEventListener("blur", onBlur);
textarea.addEventListener("scroll", onReposition);
-
+ document.addEventListener("selectionchange", onSelectionChange);
window.addEventListener("scroll", onReposition, true);
window.addEventListener("resize", onReposition);
@@ -645,20 +846,17 @@ export function attachAutocomplete(textarea, _containerEl) {
close,
destroy() {
close();
- window.removeEventListener("scroll", onReposition, true);
- window.removeEventListener("resize", onReposition);
-
- if (layoutEl) {
- layoutEl.remove();
- }
+ textarea.removeEventListener("input", onInput);
textarea.removeEventListener("keydown", onKeyDown);
textarea.removeEventListener("blur", onBlur);
textarea.removeEventListener("scroll", onReposition);
- if (typeof onInput === "function") {
- textarea.removeEventListener("input", onInput);
- }
+ document.removeEventListener("selectionchange", onSelectionChange);
+ window.removeEventListener("scroll", onReposition, true);
+ window.removeEventListener("resize", onReposition);
+ layoutEl.remove();
+ hintEl.remove();
},
};
}
-export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };
\ No newline at end of file
+export { buildSuggestions, getWordPrefix, isCursorInStringOrComment, formatArgHint };
diff --git a/web/inbuilt_symbols.js b/web/inbuilt_symbols.js
index 209cab5..93c3984 100644
--- a/web/inbuilt_symbols.js
+++ b/web/inbuilt_symbols.js
@@ -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 = {
diff --git a/web/script_text_input.js b/web/script_text_input.js
index 00f2c77..e389626 100644
--- a/web/script_text_input.js
+++ b/web/script_text_input.js
@@ -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, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
+// `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) => `${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 += `${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 => `${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.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 `${escapeHtml(t.value)}`;
- }
- return `${escapeHtml(t.value)}`;
- }).join("");
-}
\ No newline at end of file