c94e8f0acb
This pull request completely overhauls the Creator editor flow, resolves the editor duplication race condition, aligns layout/styling themes in light and dark mode, and adds Docker staging setups. ### Key Changes 1. **Creator Flow Polish**: Redesigned the editor canvas to prevent double scrolling by delegating overflow to the editor canvas layer, updated styles to a premium aesthetic. 2. **Race Condition Prevention**: Resolved Crepe editor duplication when loading or switching chapters by tracking state via shared window maps (`window.editorCache`, `window.editorStates`) and checking `_lastInitializedEditorId` synchronously in Blazor. 3. **Theme Synchronization**: Integrated explicit theme initialization (`ThemeService.InitializeAsync()`) and anchored CSS isolation selectors to correctly sync with Light (Soft Sepia) and Deep Dark theme preferences. 4. **Staging Automation**: Created staging docker configurations with `--nexus-only` flag to allow iterative development without resetting PG/Neo4j database containers. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #83 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
560 lines
19 KiB
Plaintext
560 lines
19 KiB
Plaintext
@using Microsoft.JSInterop
|
|
@implements IAsyncDisposable
|
|
@inject IJSRuntime JS
|
|
@inject HttpClient Http
|
|
@inject NexusReader.Application.Abstractions.Services.INativeStorageService StorageService
|
|
|
|
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
|
|
@if (_showRestorationBanner)
|
|
{
|
|
<div class="restoration-banner">
|
|
<span class="banner-text">You have unsaved changes from an interrupted session.</span>
|
|
<div class="banner-actions">
|
|
<button type="button" class="banner-btn restore-btn" @onclick="RestoreBackupAsync">Restore</button>
|
|
<button type="button" class="banner-btn dismiss-btn" @onclick="DismissBackupAsync">Dismiss</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div @key="_editorRenderKey" id="@EditorId" class="milkdown-editor-wrapper"></div>
|
|
|
|
<div class="editor-footer">
|
|
<div class="status-indicator">
|
|
<span class="status-dot @StatusClass"></span>
|
|
<span class="status-text">@StatusText</span>
|
|
</div>
|
|
@if (ShowFetchButton)
|
|
{
|
|
<div class="editor-actions">
|
|
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
|
|
Fetch Markdown Content
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@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<MarkdownEditor>? _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<string> 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<IJSObjectReference>(
|
|
"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<IJSObjectReference>(
|
|
"import",
|
|
$"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}"
|
|
);
|
|
}
|
|
|
|
// Sweep and filter backup keys defensively
|
|
var keys = await _module.InvokeAsync<List<string>>("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<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
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<NexusReader.Application.DTOs.Media.LocalBackupEnvelope>(
|
|
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<string>("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<string> 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.DTOs.Media.UploadResultDto>(
|
|
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();
|
|
}
|
|
}
|
|
}
|