300 lines
10 KiB
Plaintext
300 lines
10 KiB
Plaintext
@using MediatR
|
|
@using NexusReader.Application.Queries.Reader
|
|
@using Microsoft.JSInterop
|
|
@using NexusReader.UI.Shared.Services
|
|
@using Microsoft.AspNetCore.Components.Authorization
|
|
@implements IDisposable
|
|
@inject IMediator Mediator
|
|
@inject IJSRuntime JS
|
|
@inject IThemeService ThemeService
|
|
@inject IFocusModeService FocusMode
|
|
@inject IReaderNavigationService NavigationService
|
|
@inject KnowledgeCoordinator Coordinator
|
|
@inject IReaderInteractionService InteractionService
|
|
@inject ISyncService SyncService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject ILogger<ReaderCanvas> Logger
|
|
|
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
|
@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;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await Coordinator.ClearAsync();
|
|
ThemeService.OnThemeChanged += HandleUpdate;
|
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
|
|
|
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
|
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
|
InteractionService.OnTextSelected += HandleTextSelected;
|
|
SyncService.OnProgressReceived += HandleSyncProgressReceived;
|
|
}
|
|
|
|
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)
|
|
{
|
|
await SyncService.InitializeAsync();
|
|
_isInteractive = true;
|
|
if (ViewModel != null)
|
|
{
|
|
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
|
}
|
|
}
|
|
|
|
if (ViewModel != null && !_isJsInitialized)
|
|
{
|
|
_isJsInitialized = true;
|
|
await InitializeObserverAsync();
|
|
await InitializeSelectionListenerAsync();
|
|
}
|
|
}
|
|
|
|
private async Task InitializeSelectionListenerAsync()
|
|
{
|
|
try
|
|
{
|
|
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
|
await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to initialize JS selection listener. Text selection will be unavailable.");
|
|
}
|
|
}
|
|
|
|
private async Task InitializeObserverAsync()
|
|
{
|
|
try
|
|
{
|
|
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
|
|
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to initialize JS scroll observer. Reading progress sync will be unavailable.");
|
|
}
|
|
}
|
|
|
|
[JSInvokable]
|
|
public async Task HandleBlockReached(string blockId, string content)
|
|
{
|
|
_currentActiveBlockId = 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);
|
|
|
|
if (_isInteractive)
|
|
{
|
|
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
|
|
}
|
|
}
|
|
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 && !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);
|
|
}
|
|
}
|
|
|
|
public async Task ScrollToNodeAsync(string id)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to scroll to node {NodeId}.", id);
|
|
}
|
|
}
|
|
|
|
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
|
|
|
|
public void Dispose()
|
|
{
|
|
ThemeService.OnThemeChanged -= HandleUpdate;
|
|
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
|
|
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
|
InteractionService.OnTextSelected -= HandleTextSelected;
|
|
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
|
}
|
|
}
|