@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using Microsoft.Extensions.Logging @using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @implements IAsyncDisposable @inject IJSRuntime JS @inject Microsoft.Extensions.Logging.ILogger Logger @if (ShowToolbar) {
}
@if (_tooLargeBytes > 0) { @* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is @ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
} @code { /// /// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per /// keystroke (each report is one circuit message carrying the whole script). A page that persists the text /// must read the authoritative current value with at save time. /// [Parameter] public string Value { get; set; } = ""; /// Raised with the editor text when the editor loses focus after an edit (and from ). [Parameter] public EventCallback ValueChanged { get; set; } [Parameter] public string Language { get; set; } = "csharp"; [Parameter] public string Height { get; set; } = "320px"; [Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ShowToolbar { get; set; } = true; /// /// Owning-equipment context for equipment-relative {{equip}}/<RefName> completion + /// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared /// ScriptEdit page (where the token cannot resolve to a single equipment). /// [Parameter] public string? EquipmentId { get; set; } /// /// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response /// to the CURRENT text — superseded responses are dropped in the JS). Feeds ScriptProblemsList; the /// markers are sent from JS only when this callback has a subscriber, and at most /// of them (plus the total) so the report always fits in one /// circuit message. /// [Parameter] public EventCallback MarkersChanged { get; set; } private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); private string _lastSentValue = ""; private bool _initialized; private int _tooLargeBytes; private bool _wrap; private bool _minimap; private bool _dark; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { _dotNetRef = DotNetObjectReference.Create(this); _lastSentValue = Value ?? ""; try { await JS.InvokeVoidAsync( "MonacoBlazor.createEditor", _id, _hostRef, new { value = Value ?? "", language = Language, readOnly = ReadOnly, equipmentId = EquipmentId, maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes, reportMarkers = MarkersChanged.HasDelegate, maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers, maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars }, _dotNetRef); _initialized = true; } catch (InvalidOperationException) { // Prerendering: JS interop is not available yet — the next // (interactive) render retries. Expected, not logged. } catch (JSDisconnectedException) { // Circuit disconnected before init completed — nothing to do. } catch (JSException ex) { // A genuine Monaco init failure — surface it instead of hiding it. Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id); } } else if (_initialized && (Value ?? "") != _lastSentValue) { _lastSentValue = Value ?? ""; await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue); } } /// /// Invokes a Monaco JS function, swallowing the expected disconnect case but /// logging any genuine JS error so failures are not silent. /// private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args) { try { await JS.InvokeVoidAsync(fn, args); } catch (JSDisconnectedException) { // Circuit gone — the editor no longer exists; nothing to log. } catch (JSException ex) { Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action); } } [JSInvokable] public Task OnValueChanged(string newValue) { var normalized = newValue ?? ""; if (normalized == _lastSentValue) return Task.CompletedTask; _lastSentValue = normalized; return ValueChanged.InvokeAsync(_lastSentValue); } /// /// Reads the editor's current text on demand (call it at Save), raises when it /// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init) /// the bound is authoritative and is returned as-is. /// /// The current text, or an error (e.g. the script is too large to send) — never a stale value. public async Task GetValueAsync() { if (!_initialized) return new EditorReadResult(Value ?? "", null); string? current; try { current = await JS.InvokeAsync("MonacoBlazor.getValue", _id); } catch (JSDisconnectedException) { return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again."); } catch (JSException ex) { Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id); // JSException.Message carries the JS stack after the first line; the author needs only the reason. return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim()); } if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed _tooLargeBytes = 0; // it was read, so it fits await OnValueChanged(current); return new EditorReadResult(current, null); } /// The outcome of : the text, or why it could not be read. /// The editor text; null when is set. /// Why the text could not be read; null on success. public sealed record EditorReadResult(string? Value, string? Error) { /// True when holds the editor text. public bool Ok => Error is null; } [JSInvokable] public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) => MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total)); /// The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit. [JSInvokable] public void OnSyncRefused(int bytes) { if (_tooLargeBytes == bytes) return; _tooLargeBytes = bytes; StateHasChanged(); } /// Programmatic scroll-to-line (called by the problems panel). public async Task RevealLineAsync(int line, int column = 1) { if (!_initialized) return; await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column); } private async Task FormatAsync() { if (!_initialized) return; await SafeInvokeAsync("MonacoBlazor.format", "format document", _id); } private async Task ToggleWrap() { _wrap = !_wrap; await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off"); } private async Task ToggleMinimap() { _minimap = !_minimap; await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap }); } // R04 H3 (accepted, cosmetic): Monaco themes are page-global — the JS side calls // monaco.editor.setTheme, which re-themes EVERY editor on the page (e.g. a VirtualTagModal open over // ScriptEdit), not just this one, and the other instance's _dark flag then no longer matches what it shows. // Monaco has no per-editor theme; a fix would need a page-level theme service shared by all instances. private async Task ToggleTheme() { _dark = !_dark; await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs"); } public async ValueTask DisposeAsync() { if (_initialized) { // Disposal commonly races a circuit disconnect — JSDisconnectedException // here is expected and silent; a real JSException is still logged. await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id); } _dotNetRef?.Dispose(); } }