feat(mobile-ux): implement auto-hiding bars on scroll and widen reader container
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
@inject IReaderStateService StateService
|
@inject IReaderStateService StateService
|
||||||
@inject IThemeService ThemeService
|
@inject IThemeService ThemeService
|
||||||
|
|
||||||
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
<!-- Tab 1: Progress (Postęp) -->
|
<!-- Tab 1: Progress (Postęp) -->
|
||||||
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
|
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
|
||||||
<div class="progress-ring-wrapper">
|
<div class="progress-ring-wrapper">
|
||||||
@@ -112,8 +112,11 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
|
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private double GetDashOffset()
|
private double GetDashOffset()
|
||||||
@@ -160,5 +163,6 @@
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
|
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
overflow: visible; /* Critical to show elevated FAB */
|
overflow: visible; /* Critical to show elevated FAB */
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nexus-unified-mobile-toolbar.immersive-zen-mode {
|
||||||
|
transform: translateY(calc(100% + 24px)) !important;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Light Mode: Premium Paper Look */
|
/* Light Mode: Premium Paper Look */
|
||||||
.nexus-unified-mobile-toolbar.theme-light {
|
.nexus-unified-mobile-toolbar.theme-light {
|
||||||
background: rgba(244, 241, 234, 0.9);
|
background: rgba(244, 241, 234, 0.9);
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ILogger<ReaderCanvas> Logger
|
@inject ILogger<ReaderCanvas> Logger
|
||||||
|
|
||||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
@if (_isMobile && ViewModel != null)
|
@if (_isMobile && ViewModel != null)
|
||||||
{
|
{
|
||||||
<header class="nexus-mobile-reader-header">
|
<header class="nexus-mobile-reader-header @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
|
||||||
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
|
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
|
||||||
<NexusIcon Name="chevron-left" Size="18" />
|
<NexusIcon Name="chevron-left" Size="18" />
|
||||||
<span>Pulpit</span>
|
<span>Pulpit</span>
|
||||||
@@ -130,6 +130,7 @@
|
|||||||
ThemeService.OnThemeChanged += HandleThemeChanged;
|
ThemeService.OnThemeChanged += HandleThemeChanged;
|
||||||
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
NavigationService.OnNavigationChanged += OnNavigationChanged;
|
||||||
QuizService.OnQuizUpdated += HandleUpdate;
|
QuizService.OnQuizUpdated += HandleUpdate;
|
||||||
|
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
|
||||||
@@ -250,7 +251,7 @@
|
|||||||
if (_selfReference != null)
|
if (_selfReference != null)
|
||||||
{
|
{
|
||||||
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
|
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
|
||||||
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
|
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-canvas");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -266,6 +267,17 @@
|
|||||||
await InteractionService.NotifyScrollPercentChanged(percent);
|
await InteractionService.NotifyScrollPercentChanged(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task HandleScrollDelta(bool hideBars)
|
||||||
|
{
|
||||||
|
if (StateService.IsBarsHidden != hideBars)
|
||||||
|
{
|
||||||
|
StateService.IsBarsHidden = hideBars;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task HandleBlockReached(string blockId, string content)
|
public async Task HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
@@ -471,6 +483,7 @@
|
|||||||
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
ThemeService.OnThemeChanged -= HandleThemeChanged;
|
||||||
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
NavigationService.OnNavigationChanged -= OnNavigationChanged;
|
||||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||||
|
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
|
||||||
|
|
||||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
|
|||||||
@@ -344,7 +344,14 @@
|
|||||||
/* Ensure content is clear of bottom toolbar */
|
/* Ensure content is clear of bottom toolbar */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-canvas.immersive-zen-mode {
|
||||||
|
padding-top: calc(10px + env(safe-area-inset-top, 0px)) !important;
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-flow-container {
|
.reader-flow-container {
|
||||||
|
padding-left: 18px !important;
|
||||||
|
padding-right: 18px !important;
|
||||||
padding-bottom: 4rem;
|
padding-bottom: 4rem;
|
||||||
/* Safe breathing room */
|
/* Safe breathing room */
|
||||||
}
|
}
|
||||||
@@ -381,8 +388,14 @@
|
|||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nexus-mobile-reader-header.immersive-zen-mode {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.theme-light .nexus-mobile-reader-header {
|
.theme-light .nexus-mobile-reader-header {
|
||||||
background: rgba(249, 249, 249, 0.8);
|
background: rgba(249, 249, 249, 0.8);
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public interface IReaderStateService
|
|||||||
List<string> CurrentCheckpoints { get; set; }
|
List<string> CurrentCheckpoints { get; set; }
|
||||||
string CurrentBlockId { get; set; }
|
string CurrentBlockId { get; set; }
|
||||||
MobileReaderTab ActiveTab { get; set; }
|
MobileReaderTab ActiveTab { get; set; }
|
||||||
|
bool IsBarsHidden { get; set; }
|
||||||
|
event Func<Task>? OnBarsHiddenChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public sealed class ReaderStateService : IReaderStateService
|
|||||||
private List<string> _checkpoints = new();
|
private List<string> _checkpoints = new();
|
||||||
private string _blockId = string.Empty;
|
private string _blockId = string.Empty;
|
||||||
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
||||||
|
private bool _barsHidden;
|
||||||
|
|
||||||
|
public event Func<Task>? OnBarsHiddenChanged;
|
||||||
|
|
||||||
public int CurrentScrollPercentage
|
public int CurrentScrollPercentage
|
||||||
{
|
{
|
||||||
@@ -38,4 +41,23 @@ public sealed class ReaderStateService : IReaderStateService
|
|||||||
get { lock (_lock) return _activeTab; }
|
get { lock (_lock) return _activeTab; }
|
||||||
set { lock (_lock) _activeTab = value; }
|
set { lock (_lock) _activeTab = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsBarsHidden
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _barsHidden; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
bool changed;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
changed = _barsHidden != value;
|
||||||
|
_barsHidden = value;
|
||||||
|
}
|
||||||
|
if (changed && OnBarsHiddenChanged != null)
|
||||||
|
{
|
||||||
|
_ = OnBarsHiddenChanged.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
|
|
||||||
let isThrottled = false;
|
let isThrottled = false;
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (isThrottled) return;
|
if (isThrottled) return;
|
||||||
@@ -44,6 +45,17 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
// Ensure bounds
|
// Ensure bounds
|
||||||
percentage = Math.max(0, Math.min(100, percentage));
|
percentage = Math.max(0, Math.min(100, percentage));
|
||||||
|
|
||||||
|
// Scroll delta detection:
|
||||||
|
// Hide bars on scroll down, show on scroll up. Force show when close to top.
|
||||||
|
const delta = scrollTop - lastScrollTop;
|
||||||
|
if (scrollTop <= 10) {
|
||||||
|
dotNetHelper.invokeMethodAsync('HandleScrollDelta', false);
|
||||||
|
} else if (Math.abs(delta) > 5) {
|
||||||
|
const hideBars = delta > 0;
|
||||||
|
dotNetHelper.invokeMethodAsync('HandleScrollDelta', hideBars);
|
||||||
|
}
|
||||||
|
lastScrollTop = scrollTop;
|
||||||
|
|
||||||
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
|
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
|
||||||
isThrottled = false;
|
isThrottled = false;
|
||||||
});
|
});
|
||||||
@@ -60,3 +72,4 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user