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 @@
-
-
- - {{ line }} -
-
+
{{ gutterText }}
@@ -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