@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 Logger
@if (_isMobile && ViewModel != null) {
@ViewModel.ChapterTitle
} @if (ViewModel == null) {
@StatusMessage
} else {
@foreach (var block in ViewModel.Blocks) {
@if (block is TextSegmentBlock textSegment) { @((MarkupString)textSegment.Content) }
}
@if (_isLoadingChapter) {
Wczytywanie kolejnego rozdziału...
} }
@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? _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 EnsureViewportModuleAsync() { if (_viewportModule == null) { _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); } return _viewportModule; } private async Task InitViewportDetectionAsync() { try { var module = await EnsureViewportModuleAsync(); var isMobileViewport = await module.InvokeAsync("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("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("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("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() .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(); } }