@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject HttpClient Http
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
@if (_showRestorationBanner)
{
You have unsaved changes from an interrupted session.
}
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();
}
}
}