@using Microsoft.JSInterop @implements IAsyncDisposable @inject IJSRuntime JS @inject HttpClient Http @inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
@if (_showRestorationBanner) {
} else {
}
@code { private string EditorId { get; set; } = $"milkdown-editor-{Guid.NewGuid():N}"; private Guid _editorRenderKey = Guid.NewGuid(); private readonly CancellationTokenSource _cts = new(); private IJSObjectReference? _module; private DotNetObjectReference? _dotNetHelper; private string? _lastInitializedEditorId; private bool _disposed; private enum SaveStatus { SavedToCloud, Saving, OfflineLocalBackup } private SaveStatus _status = SaveStatus.SavedToCloud; private string _currentMarkdown = string.Empty; private CancellationTokenSource? _debounceCts; private readonly object _timerLock = new(); private bool _showRestorationBanner = false; private NexusReader.Application.DTOs.Media.LocalBackupEnvelope? _pendingBackup; private bool _hasRunStorageInit = false; private bool _reinitializeEditor = false; private string StatusClass => _status switch { SaveStatus.SavedToCloud => "saved", SaveStatus.Saving => "saving", SaveStatus.OfflineLocalBackup => "offline", _ => "saved" }; private string StatusText => _status switch { SaveStatus.SavedToCloud => "Saved to Cloud", SaveStatus.Saving => "Saving...", SaveStatus.OfflineLocalBackup => "Offline - Local Backup Only", _ => "Saved to Cloud" }; [Parameter] public bool ShowFetchButton { get; set; } = true; [Parameter] public string InitialMarkdown { get; set; } = string.Empty; [Parameter] public EventCallback OnSave { get; set; } [Parameter] public string Height { get; set; } = "500px"; [Parameter] public string Width { get; set; } = "100%"; [Parameter] public Guid ChapterId { get; set; } = Guid.Empty; [Parameter] public DateTime? ServerTimestamp { get; set; } protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); // Sweep keys and check restoration on init await RunStorageSweepAndRestorationCheckAsync(); } private Guid _prevChapterId = Guid.Empty; protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); if (ChapterId != Guid.Empty && ChapterId != _prevChapterId) { _prevChapterId = ChapterId; _hasRunStorageInit = false; if (_module != null) { try { await _module.InvokeVoidAsync("destroyEditor", EditorId); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error destroying old editor on chapter switch: {ex.Message}"); } } _reinitializeEditor = true; EditorId = $"milkdown-editor-{Guid.NewGuid():N}"; _editorRenderKey = Guid.NewGuid(); await RunStorageSweepAndRestorationCheckAsync(); } } protected override async Task OnAfterRenderAsync(bool firstRender) { var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId); if (shouldInit) { _reinitializeEditor = false; _lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers if (firstRender) { _dotNetHelper = DotNetObjectReference.Create(this); // Retry if deferred during prerendering OnInitializedAsync await RunStorageSweepAndRestorationCheckAsync(); } try { if (_module == null) { _module = await JS.InvokeAsync( "import", $"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}" ); } await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}"); } } } private async Task RunStorageSweepAndRestorationCheckAsync() { if (_hasRunStorageInit) return; try { _hasRunStorageInit = true; // Import wrapper module if not already loaded to access helper if (_module == null) { _module = await JS.InvokeAsync( "import", $"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}" ); } // Sweep and filter backup keys defensively var keys = await _module.InvokeAsync>("getBackupKeys"); if (keys != null) { var now = DateTime.UtcNow; foreach (var key in keys) { // Strict defensive check before doing any JSON deserialization if (!key.StartsWith("nexus-bkp-")) continue; try { var backupResult = await StorageService.GetStringAsync(key); if (backupResult.IsSuccess && !string.IsNullOrEmpty(backupResult.Value)) { var envelope = System.Text.Json.JsonSerializer.Deserialize( backupResult.Value, NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope ); if (envelope != null) { // Remove expired backups if ((now - envelope.Timestamp).TotalDays > 7) { await StorageService.RemoveAsync(key); Console.WriteLine($"[MarkdownEditor] Boot-up Eviction: Deleted expired backup key {key}"); } } } } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error sweeping key {key}: {ex.Message}"); } } } // Restoration guard for this specific Chapter ID var currentBackupKey = $"nexus-bkp-{ChapterId}"; var currentBackupResult = await StorageService.GetStringAsync(currentBackupKey); if (currentBackupResult.IsSuccess && !string.IsNullOrEmpty(currentBackupResult.Value)) { var envelope = System.Text.Json.JsonSerializer.Deserialize( currentBackupResult.Value, NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope ); if (envelope != null) { var serverTime = ServerTimestamp ?? DateTime.MinValue; if (envelope.Timestamp > serverTime && envelope.MarkdownContent != InitialMarkdown) { _pendingBackup = envelope; _showRestorationBanner = true; StateHasChanged(); } } } } catch (Exception ex) { _hasRunStorageInit = false; // Reset to allow retry on client render Console.WriteLine($"[MarkdownEditor] Storage initialization deferred/failed: {ex.Message}"); } } private async Task RestoreBackupAsync() { if (_pendingBackup != null) { if (_module != null) { try { // Prevent memory leak by cleaning up old instance in JS await _module.InvokeVoidAsync("destroyEditor", EditorId); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error destroying old editor during restore: {ex.Message}"); } } InitialMarkdown = _pendingBackup.MarkdownContent; _showRestorationBanner = false; _pendingBackup = null; // Regenerate render key and ID to trigger clean Blazor element-level re-initialization _reinitializeEditor = true; EditorId = $"milkdown-editor-{Guid.NewGuid():N}"; _editorRenderKey = Guid.NewGuid(); // Trigger an immediate background API autosave to synchronize the database with the restored content _ = TriggerAutosaveAsync(InitialMarkdown, _cts.Token); StateHasChanged(); } } private async Task DismissBackupAsync() { _showRestorationBanner = false; _pendingBackup = null; try { await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}"); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Failed to dismiss backup from LocalStorage: {ex.Message}"); } StateHasChanged(); } public async Task FetchContentAsync() { if (_module is not null) { try { var markdown = await _module.InvokeAsync("getMarkdownContent", EditorId); if (OnSave.HasDelegate) { await OnSave.InvokeAsync(markdown); } } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}"); } } } [JSInvokable] public async Task OnEditorContentChanged(string currentMarkdown) { _currentMarkdown = currentMarkdown; // Structured JSON Envelope Pattern var envelope = new NexusReader.Application.DTOs.Media.LocalBackupEnvelope { ChapterId = ChapterId, Timestamp = DateTime.UtcNow, MarkdownContent = currentMarkdown }; try { var envelopeJson = System.Text.Json.JsonSerializer.Serialize( envelope, NexusReader.Application.Common.AppJsonContext.Default.LocalBackupEnvelope ); await StorageService.SaveStringAsync($"nexus-bkp-{ChapterId}", envelopeJson); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Failed to save backup to LocalStorage: {ex.Message}"); } // Status indicator to Offline - Local Backup Only _status = SaveStatus.OfflineLocalBackup; await InvokeAsync(StateHasChanged); // Cancel pending timers thread-safely CancellationTokenSource? ctsToCancel = null; CancellationToken token; lock (_timerLock) { if (_debounceCts != null) { ctsToCancel = _debounceCts; _debounceCts = null; } _debounceCts = new CancellationTokenSource(); token = _debounceCts.Token; // Capture token synchronously under lock on UI thread } if (ctsToCancel != null) { try { await ctsToCancel.CancelAsync(); ctsToCancel.Dispose(); } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Error cancelling debounce timer: {ex.Message}"); } } // Start 5-second idle debounce timer _ = Task.Run(async () => { try { await Task.Delay(5000, token); await TriggerAutosaveAsync(currentMarkdown, token); } catch (TaskCanceledException) { // Task cancelled on new keystroke } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Debounce timer exception: {ex.Message}"); } }); } private async Task TriggerAutosaveAsync(string markdown, CancellationToken token) { if (token.IsCancellationRequested || _disposed) return; _status = SaveStatus.Saving; await InvokeAsync(StateHasChanged); try { var request = new NexusReader.Application.DTOs.Media.AutosaveChapterRequest(markdown); var response = await Http.PutAsJsonAsync( $"/api/chapters/{ChapterId}/autosave", request, NexusReader.Application.Common.AppJsonContext.Default.AutosaveChapterRequest, token ); if (_disposed) return; if (response.IsSuccessStatusCode) { // Purge LocalStorage backup key on HTTP success await StorageService.RemoveAsync($"nexus-bkp-{ChapterId}"); _status = SaveStatus.SavedToCloud; } else { _status = SaveStatus.OfflineLocalBackup; var errorMsg = await response.Content.ReadAsStringAsync(token); Console.WriteLine($"[MarkdownEditor] Autosave HTTP error: {response.StatusCode} - {errorMsg}"); } } catch (Exception ex) { if (_disposed) return; _status = SaveStatus.OfflineLocalBackup; Console.WriteLine($"[MarkdownEditor] Autosave HTTP exception: {ex.Message}"); } if (_disposed) return; await InvokeAsync(StateHasChanged); } [JSInvokable] public async Task UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef) { try { const long maxFileSize = 5 * 1024 * 1024; // 5MB limit using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token); using var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream, _cts.Token); var fileBytes = memoryStream.ToArray(); using var content = new MultipartFormDataContent(); using var fileContent = new ByteArrayContent(fileBytes); fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); content.Add(fileContent, "file", filename); var response = await Http.PostAsync("/api/media/upload", content, _cts.Token); if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync( NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token); return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed"; } else { var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token); Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}"); return "https://placehold.co/600x400?text=Upload+Failed"; } } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}"); return "https://placehold.co/600x400?text=Upload+Failed"; } } public async ValueTask DisposeAsync() { _disposed = true; try { _cts.Cancel(); _cts.Dispose(); } catch { // Fail silently } CancellationTokenSource? ctsToCancel = null; lock (_timerLock) { if (_debounceCts != null) { ctsToCancel = _debounceCts; _debounceCts = null; } } if (ctsToCancel != null) { try { ctsToCancel.Cancel(); ctsToCancel.Dispose(); } catch { // Fail silently } } try { // Always try to destroy via global window registration first to handle null _module await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId); } catch { // Fallback to module if global is not set if (_module is not null) { try { await _module.InvokeVoidAsync("destroyEditor", EditorId); } catch { // Fail silently } } } try { if (_module is not null) { await _module.DisposeAsync(); } } catch (JSDisconnectedException) { // Fail silently during circuit disconnection } catch (ObjectDisposedException) { // Fail silently if JS runtime/module is already disposed } catch (Exception ex) { Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}"); } finally { _dotNetHelper?.Dispose(); } } }