+ style="@(IsUploadActive ? "display:flex;" : "display:none;")"
+ @ondragenter="OnDragEnter"
+ @ondragleave="OnDragLeave">
@@ -143,6 +150,7 @@
private string? ErrorMessage { get; set; }
private byte[]? _epubBytes;
private bool _disposed;
+ private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing;
// Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024;
@@ -163,6 +171,8 @@
if (!_disposed)
{
+ // Dispatch the state change to the Blazor synchronization context
+ // because this event is triggered asynchronously from a SignalR / WebSocket background thread.
await InvokeAsync(StateHasChanged);
}
@@ -177,6 +187,8 @@
if (IngestedBookId != Guid.Empty)
{
var bookId = IngestedBookId;
+ // Dispatch UI updates and navigation back to the Blazor thread
+ // to avoid thread affinity issues and potential UI lockups in MAUI/Web applications.
await InvokeAsync(async () => {
if (_disposed) return;
await CloseModal();
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
index 519639b..d92d068 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
+++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
@@ -118,8 +118,9 @@
z-index: 10;
}
-/* Parsing State */
-.parsing-state {
+/* Parsing and Ingesting States */
+.parsing-state,
+.ingesting-state {
flex: 1;
display: flex;
justify-content: center;
@@ -158,7 +159,8 @@
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
}
-.parsing-state p {
+.parsing-state p,
+.ingesting-state p {
color: var(--nexus-text);
font-family: var(--nexus-font-mono, monospace);
font-size: 0.9rem;
@@ -371,10 +373,11 @@
position: absolute;
width: 20px;
height: 20px;
- border: 2px solid rgba(0, 0, 0, 0.1);
- border-top-color: #000;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-top-color: var(--nexus-neon, #00ffaa);
border-radius: 50%;
animation: spin 0.8s linear infinite;
+ filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa));
}
/* Indexing State */
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
index e9a919d..98aa195 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
+++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
@@ -159,15 +159,24 @@
}
}
+ private async ValueTask EnsureViewportModuleAsync()
+ {
+ if (_viewportModule == null)
+ {
+ _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
+ }
+ return _viewportModule;
+ }
+
private async Task InitViewportDetectionAsync()
{
try
{
- _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
+ var module = await EnsureViewportModuleAsync();
_selfReference = DotNetObjectReference.Create(this);
- var isMobileViewport = await _viewportModule.InvokeAsync("isMobileViewport");
+ var isMobileViewport = await module.InvokeAsync("isMobileViewport");
await OnViewportChanged(isMobileViewport);
- await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
+ await module.InvokeVoidAsync("registerViewportObserver", _selfReference);
}
catch (Exception ex)
{
@@ -348,16 +357,25 @@
_isLoadingChapter = false;
StateHasChanged();
- if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
+ if (result.IsSuccess)
{
- var targetBlockId = NavigationService.PendingScrollBlockId;
- NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
- _currentActiveBlockId = targetBlockId;
+ if (!string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
+ {
+ var targetBlockId = NavigationService.PendingScrollBlockId;
+ NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
+ _currentActiveBlockId = targetBlockId;
- // Give the browser slightly more than one frame to render the loaded blocks
- await Task.Delay(150);
- await ScrollToNodeAsync(targetBlockId);
- await InteractionService.RequestHighlightBlock(targetBlockId);
+ // Give the browser slightly more than one frame to render the loaded blocks
+ await Task.Delay(150);
+ await ScrollToNodeAsync(targetBlockId);
+ await InteractionService.RequestHighlightBlock(targetBlockId);
+ }
+ else
+ {
+ // Reset scroll to top now that the new content DOM is rendered
+ await Task.Delay(50); // Give the browser a frame to render the new chapter content
+ await ScrollToTopAsync();
+ }
}
}
@@ -365,7 +383,7 @@
{
try
{
- var module = _viewportModule ?? await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
+ var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollIntoView", id);
}
catch (Exception ex)
@@ -374,6 +392,19 @@
}
}
+ public async Task ScrollToTopAsync()
+ {
+ try
+ {
+ var module = await EnsureViewportModuleAsync();
+ await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to scroll reader canvas to top.");
+ }
+ }
+
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private void HandleEscape()
diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
index 1986436..cdf2008 100644
--- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
+++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace NexusReader.UI.Shared.Services;
-public sealed partial class KnowledgeCoordinator : IDisposable
+public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
{
private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService;
@@ -16,6 +16,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
private readonly IPlatformService _platformService;
private readonly IReaderInteractionService _interactionService;
private readonly ILogger _logger;
+
+ private CancellationTokenSource? _graphCts;
+ private CancellationTokenSource? _quizCts;
public string CurrentFullPageContent { get; private set; } = string.Empty;
@@ -75,9 +78,38 @@ public sealed partial class KnowledgeCoordinator : IDisposable
}
}
+ private void CancelAndDisposeCts(ref CancellationTokenSource? cts)
+ {
+ var localCts = cts;
+ cts = null;
+ if (localCts != null)
+ {
+ try
+ {
+ localCts.Cancel();
+ }
+ catch (ObjectDisposedException) { }
+ finally
+ {
+ localCts.Dispose();
+ }
+ }
+ }
+
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
{
- if (string.IsNullOrWhiteSpace(fullContent)) return;
+ if (string.IsNullOrWhiteSpace(fullContent))
+ {
+ CancelAndDisposeCts(ref _graphCts);
+ await _graphService.Clear();
+ await _graphService.SetLoading(false);
+ CurrentFullPageContent = string.Empty;
+ return;
+ }
+
+ CancelAndDisposeCts(ref _graphCts);
+ _graphCts = new CancellationTokenSource();
+ var token = _graphCts.Token;
CurrentFullPageContent = fullContent;
LogGeneratingGraph(tenantId);
@@ -87,7 +119,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
try
{
- var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId);
+ var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
+ token.ThrowIfCancellationRequested();
+
if (result.IsSuccess)
{
var packet = result.Value;
@@ -103,10 +137,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
await _graphService.SetLoading(false);
}
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
+ }
catch (Exception ex)
{
- await _graphService.SetLoading(false);
- LogGraphError(ex, tenantId);
+ if (!token.IsCancellationRequested)
+ {
+ await _graphService.SetLoading(false);
+ LogGraphError(ex, tenantId);
+ }
}
}
@@ -118,11 +159,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{
+ CancelAndDisposeCts(ref _quizCts);
+ _quizCts = new CancellationTokenSource();
+ var token = _quizCts.Token;
+
await _quizService.SetHydrating(true);
LogRequestingSummary(tenantId);
try
{
- var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
+ var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
+ token.ThrowIfCancellationRequested();
+
if (result.IsSuccess)
{
var packet = result.Value;
@@ -138,10 +185,19 @@ public sealed partial class KnowledgeCoordinator : IDisposable
LogSummaryWarning(tenantId);
return Result.Fail(result.Errors);
}
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("[KnowledgeCoordinator] Quiz and summary generation task was canceled.");
+ return Result.Fail("Task canceled");
+ }
catch (Exception ex)
{
- LogSummaryError(ex, tenantId);
- return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
+ if (!token.IsCancellationRequested)
+ {
+ LogSummaryError(ex, tenantId);
+ return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
+ }
+ return Result.Fail("Task canceled");
}
finally
{
@@ -151,6 +207,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync()
{
+ CancelAndDisposeCts(ref _graphCts);
+ CancelAndDisposeCts(ref _quizCts);
+
CurrentFullPageContent = string.Empty;
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
@@ -159,6 +218,27 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public void Dispose()
{
_interactionService.OnNodeSelected -= HandleNodeSelected;
+
+ CancelAndDisposeCts(ref _graphCts);
+ CancelAndDisposeCts(ref _quizCts);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _interactionService.OnNodeSelected -= HandleNodeSelected;
+
+ CancelAndDisposeCts(ref _graphCts);
+ CancelAndDisposeCts(ref _quizCts);
+
+ try
+ {
+ await _graphService.Clear();
+ await _quizService.SetQuiz(null, null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error clearing services during KnowledgeCoordinator disposal.");
+ }
}
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
diff --git a/src/NexusReader.UI.Shared/wwwroot/js/viewport.js b/src/NexusReader.UI.Shared/wwwroot/js/viewport.js
index 8c02aaf..e456f4c 100644
--- a/src/NexusReader.UI.Shared/wwwroot/js/viewport.js
+++ b/src/NexusReader.UI.Shared/wwwroot/js/viewport.js
@@ -38,3 +38,16 @@ export function scrollIntoView(id) {
}
return false;
}
+
+// NOTE: Assumes the selector matches the active scroll container (default '.reader-canvas').
+// Scoping is flexible to avoid issues if SSR pre-render or animated layouts render multiple wrappers.
+export function scrollToTop(selector = '.reader-canvas') {
+ const el = document.querySelector(selector);
+ if (el) {
+ el.scrollTop = 0;
+ return true;
+ }
+ return false;
+}
+
+