Files
Nexus.Reader/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor
T

514 lines
18 KiB
Plaintext

@using MediatR
@using NexusReader.Application.Queries.Reader
@using Microsoft.JSInterop
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization
@implements IAsyncDisposable
@inject IMediator Mediator
@inject IJSRuntime JS
@inject IThemeService ThemeService
@inject IFocusModeService FocusMode
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
@inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider
@inject IQuizStateService QuizService
@inject IPlatformService PlatformService
@inject NavigationManager Navigation
@inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (_isMobile && ViewModel != null)
{
<header class="nexus-mobile-reader-header">
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
<NexusIcon Name="chevron-left" Size="18" />
<span>Pulpit</span>
</button>
<div class="nexus-mobile-chapter-navigation">
<button class="nexus-chapter-nav-btn prev" @onclick="NavigationService.GoToPreviousChapter" disabled="@(NavigationService.CurrentChapterIndex == 0)" aria-label="Poprzedni rozdział">
<NexusIcon Name="chevron-left" Size="14" />
</button>
<div class="nexus-mobile-chapter-title">
@ViewModel.ChapterTitle
</div>
<button class="nexus-chapter-nav-btn next" @onclick="NavigationService.GoToNextChapter" disabled="@(NavigationService.CurrentChapterIndex >= NavigationService.TotalChapters - 1)" aria-label="Następny rozdział">
<NexusIcon Name="chevron-right" Size="14" />
</button>
</div>
<button class="nexus-theme-toggle-btn" @onclick="ThemeService.ToggleTheme" aria-label="Przełącz motyw" title="Przełącz motyw">
@if (ThemeService.IsLightMode)
{
<svg class="theme-toggle-icon sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M6.34 17.66l-1.41 1.41"></path>
<path d="M19.07 4.93l-1.41 1.41"></path>
</svg>
}
else
{
<svg class="theme-toggle-icon moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
}
</button>
</header>
}
@if (ViewModel == null)
{
<div class="loading-state full-page">
<div class="spinner-glow"></div>
<NexusTypography Variant="NexusTypography.TypographyVariant.UI" Class="loading-text">@StatusMessage</NexusTypography>
</div>
}
else
{
<div @ref="_containerRef" class="reader-flow-container @(_isLoadingChapter ? "content-blurred" : "")">
@foreach (var block in ViewModel.Blocks)
{
<div id="@block.Id" class="block-wrapper @(_highlightedBlockId == block.Id ? "highlighted" : "")">
@if (block is TextSegmentBlock textSegment)
{
<NexusTypography Variant="NexusTypography.TypographyVariant.Ebook">@((MarkupString)textSegment.Content)</NexusTypography>
}
</div>
}
</div>
@if (_isLoadingChapter)
{
<div class="chapter-loading-overlay">
<div class="loader-card glass-panel">
<div class="spinner-glow small"></div>
<span class="loader-text">Wczytywanie kolejnego rozdziału...</span>
</div>
</div>
}
}
<SelectionAiPanel
SelectedText="@_selectedText"
BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" />
</div>
@code {
private ReaderPageViewModel? ViewModel;
private string StatusMessage = "Loading chapter...";
private bool _isLoadingChapter;
private string _selectedText = string.Empty;
private string _selectedBlockId = string.Empty;
private SelectionCoordinates? _selectionCoords;
private string? _highlightedBlockId;
private bool _isJsInitialized;
private ElementReference _containerRef;
private bool _isInteractive;
private string? _currentActiveBlockId;
private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference;
private IJSObjectReference? _viewportModule;
private IJSObjectReference? _selectionModule;
protected override async Task OnInitializedAsync()
{
await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
var context = PlatformService.GetDeviceContext();
if (context.IsSuccess)
{
_isMobile = context.Value.DeviceType switch
{
DeviceType.Phone or DeviceType.Tablet => true,
_ => false
};
}
}
protected override async Task OnParametersSetAsync()
{
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
}
private async Task OnNavigationChanged()
{
_isJsInitialized = false;
_selectedText = string.Empty;
_selectionCoords = null;
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
StateHasChanged();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_selfReference = DotNetObjectReference.Create(this);
await SyncService.InitializeAsync();
_isInteractive = true;
if (ViewModel != null)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
}
await InitViewportDetectionAsync();
}
if (ViewModel != null && !_isJsInitialized)
{
_isJsInitialized = true;
await InitializeObserverAsync();
await InitializeSelectionListenerAsync();
}
}
private async ValueTask<IJSObjectReference> EnsureViewportModuleAsync()
{
if (_viewportModule == null)
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
}
return _viewportModule;
}
private async Task InitViewportDetectionAsync()
{
try
{
var module = await EnsureViewportModuleAsync();
var isMobileViewport = await module.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport);
if (_selfReference != null)
{
await module.InvokeVoidAsync("registerViewportObserver", _selfReference);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection in ReaderCanvas.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
await InvokeAsync(StateHasChanged);
}
}
private async Task InitializeSelectionListenerAsync()
{
try
{
if (_selectionModule == null)
{
_selectionModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
}
if (_selfReference != null)
{
await _selectionModule.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize JS selection listener. Text selection will be unavailable.");
}
}
private IJSObjectReference? _scrollListenerReference;
private async Task InitializeObserverAsync()
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
if (_selfReference != null)
{
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize JS scroll observer. Reading progress sync will be unavailable.");
}
}
[JSInvokable]
public async Task HandleScrollPercentChanged(int percent)
{
StateService.CurrentScrollPercentage = percent;
await InteractionService.NotifyScrollPercentChanged(percent);
}
[JSInvokable]
public async Task HandleBlockReached(string blockId, string content)
{
_currentActiveBlockId = blockId;
StateService.CurrentBlockId = blockId;
await InteractionService.NotifyBlockReached(blockId);
await Coordinator.OnBlockReachedAsync(blockId, content);
if (ViewModel != null)
{
double progress = ((double)(ViewModel.CurrentChapterIndex + 1) / ViewModel.TotalChapters) * 100;
await SyncService.UpdateProgressAsync(
blockId,
ViewModel.EbookId,
progress,
ViewModel.ChapterTitle,
ViewModel.CurrentChapterIndex);
}
}
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
{
if (string.IsNullOrEmpty(blockId) || blockId == _currentActiveBlockId)
{
Logger.LogDebug("[Sync] Received progress {BlockId} is empty or matches active block. Ignoring scroll.", blockId);
return;
}
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
_currentActiveBlockId = blockId;
await ScrollToNodeAsync(blockId);
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task HandleTextSelected(string text, string blockId, SelectionCoordinates coords)
{
Logger.LogDebug("[ReaderCanvas] Text selected in block {BlockId}", blockId);
_selectedText = text;
_selectedBlockId = blockId;
_selectionCoords = coords;
await InvokeAsync(StateHasChanged);
}
[JSInvokable]
public async Task HandleSelectionCleared()
{
_selectedText = string.Empty;
_selectionCoords = null;
await InvokeAsync(StateHasChanged);
}
private async Task HandleScrollRequested(string blockId)
{
await ScrollToNodeAsync(blockId);
}
private async Task HandleHighlightRequested(string blockId)
{
_highlightedBlockId = blockId;
await InvokeAsync(StateHasChanged);
await Task.Delay(3000);
if (_highlightedBlockId == blockId)
{
_highlightedBlockId = null;
await InvokeAsync(StateHasChanged);
}
}
private string GetFullPageContent()
{
if (ViewModel == null) return string.Empty;
return string.Join("\n\n", ViewModel.Blocks
.OfType<TextSegmentBlock>()
.Select(b => $"[ID: {b.Id}]\n{b.Content}"));
}
private async Task LoadChapterAsync(int index)
{
await Coordinator.ClearAsync();
_isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements!
_isLoadingChapter = true;
StatusMessage = "Wczytywanie treści...";
StateHasChanged();
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var ebookId = NavigationService.CurrentEbookId;
if (ebookId == Guid.Empty)
{
ViewModel = null;
StatusMessage = "Brak wybranej książki. Otwórz książkę z biblioteki.";
_isLoadingChapter = false;
return;
}
var result = await Mediator.Send(new GetReaderPageQuery(ebookId, index, userId));
if (result.IsSuccess)
{
ViewModel = result.Value;
await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Populate checkpoints!
var checkpoints = ViewModel.Blocks
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
.Select(b => b.Id)
.ToList();
StateService.CurrentCheckpoints = checkpoints;
if (_isInteractive)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
}
}
else
{
ViewModel = null;
StatusMessage = $"Błąd: {result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wczytać treści"}";
Logger.LogError("Failed to load chapter {Index} for ebook {EbookId}: {Errors}", index, ebookId, string.Join(", ", result.Errors.Select(e => e.Message)));
}
_isLoadingChapter = false;
StateHasChanged();
if (result.IsSuccess)
{
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);
}
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();
}
}
}
public async Task ScrollToNodeAsync(string id)
{
try
{
var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollIntoView", id);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll to node {NodeId}.", id);
}
}
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()
{
if (ViewModel != null)
{
Navigation.NavigateTo("/");
}
}
private async Task HandleAssistantFabClick()
{
await InteractionService.RequestAssistant();
}
public async ValueTask DisposeAsync()
{
ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
try
{
if (_selectionModule != null)
{
await _selectionModule.InvokeVoidAsync("destroySelectionListener");
await _selectionModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to destroy JS selection listener.");
}
try
{
if (_viewportModule != null)
{
if (_selfReference != null)
{
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
}
await _viewportModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of viewport observer module failed in ReaderCanvas disposal.");
}
try
{
if (_scrollListenerReference != null)
{
await _scrollListenerReference.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of scroll listener reference failed in ReaderCanvas disposal.");
}
_selfReference?.Dispose();
}
}