514 lines
18 KiB
Plaintext
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();
|
|
}
|
|
}
|