WIP infinite loader component
This commit is contained in:
parent
76aa51dc7a
commit
40871e24aa
9 changed files with 634 additions and 382 deletions
|
|
@ -0,0 +1,8 @@
|
|||
namespace LANCommander.UI.Components.InfiniteLoader;
|
||||
|
||||
public class InfiniteLoadResponse<T>
|
||||
{
|
||||
public T? Next { get; set; }
|
||||
public IEnumerable<T>? Items { get; set; }
|
||||
public bool HasMore { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
@typeparam T
|
||||
|
||||
<div class="infinite-scroll" @ref="_scrollHost">
|
||||
<div class="sentinel" @ref="_sentinel"></div>
|
||||
|
||||
@foreach (var i in _items)
|
||||
{
|
||||
<div @key="@_keySelector?.Invoke(i)" data-index="@_keySelector?.Invoke(i)">
|
||||
@ChildContent?.Invoke(i)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
using System.Linq.Expressions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace LANCommander.UI.Components.InfiniteLoader;
|
||||
|
||||
public partial class InfiniteLoader<T> : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public Func<T, int, Task<InfiniteLoadResponse<T>>>? Loader { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PageSize { get; set; } = 10;
|
||||
|
||||
[Parameter]
|
||||
public Expression<Func<T, string>>? KeySelector { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<T>? ChildContent { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; }
|
||||
|
||||
private readonly List<T> _items = new();
|
||||
private T? _next;
|
||||
private bool _isLoadingMore;
|
||||
private bool _hasMore = true;
|
||||
|
||||
private Func<T, string>? _keySelector;
|
||||
|
||||
private ElementReference _scrollHost;
|
||||
private ElementReference _sentinel;
|
||||
|
||||
private InfiniteScrollInterop? _scrollInterop;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
await LoadInitialAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
_scrollInterop = new InfiniteScrollInterop(JS);
|
||||
|
||||
await _scrollInterop.InitializeAsync(_scrollHost, _sentinel);
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (KeySelector is null)
|
||||
throw new ArgumentNullException(nameof(KeySelector));
|
||||
|
||||
_keySelector = KeySelector.Compile();
|
||||
}
|
||||
|
||||
private async Task LoadInitialAsync()
|
||||
{
|
||||
if (Loader == null)
|
||||
throw new ArgumentNullException($"{nameof(Loader)} is null");
|
||||
|
||||
if (_next is null)
|
||||
return;
|
||||
|
||||
var response = await Loader(_next, PageSize);
|
||||
|
||||
_items.Clear();
|
||||
|
||||
if (response.Items is not null)
|
||||
_items.AddRange(response.Items);
|
||||
|
||||
_hasMore = response.HasMore;
|
||||
_next = response.Next;
|
||||
}
|
||||
|
||||
private async Task LoadMoreAsync(object? anchor)
|
||||
{
|
||||
if (_isLoadingMore || !_hasMore || Loader is null || _next is null)
|
||||
return;
|
||||
|
||||
_isLoadingMore = true;
|
||||
|
||||
try
|
||||
{
|
||||
var page = await Loader.Invoke(_next, PageSize);
|
||||
|
||||
if (page.Items is not null)
|
||||
_items.InsertRange(0, page.Items);
|
||||
|
||||
_hasMore = page.HasMore;
|
||||
_next = page.Next;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await _scrollInterop!.RestoreAfterPrependAsync(_scrollHost, anchor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnSentinelVisible()
|
||||
{
|
||||
if (_scrollInterop is null)
|
||||
return;
|
||||
|
||||
var anchor = await _scrollInterop.CaptureAnchorAsync(_scrollHost);
|
||||
|
||||
await LoadMoreAsync(anchor);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollInterop is not null)
|
||||
await _scrollInterop.DisposeAsync();
|
||||
}
|
||||
}
|
||||
50
LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts
Normal file
50
LANCommander.UI/Components/InfiniteLoader/InfiniteScroll.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {InfiniteScrollAnchor} from "./InfiniteScrollAnchor";
|
||||
declare const DotNet: typeof import("@microsoft/dotnet-js-interop").DotNet;
|
||||
|
||||
export function ObserveSentinel(scrollHost: Element, sentinel: Element) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
DotNet.invokeMethodAsync("LANCommander.UI", "OnSentinelVisible");
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: scrollHost,
|
||||
threshold: 0.01
|
||||
});
|
||||
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
export function CaptureAnchor(scrollHost: Element): InfiniteScrollAnchor {
|
||||
const first = scrollHost.querySelector("[data-id]");
|
||||
|
||||
if (!first)
|
||||
return null;
|
||||
|
||||
const rect = first.getBoundingClientRect();
|
||||
const hostRect = scrollHost.getBoundingClientRect();
|
||||
|
||||
const anchor = new InfiniteScrollAnchor();
|
||||
|
||||
anchor.Id = first.getAttribute("data-id");
|
||||
anchor.OffsetTop = rect.top - hostRect.top;
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
export function RestoreAfterPrepend(scrollHost: Element, anchor: InfiniteScrollAnchor) {
|
||||
if (!anchor)
|
||||
return;
|
||||
|
||||
const el = scrollHost.querySelector(`[data-id="${anchor.Id}"]`);
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const hostRect = scrollHost.getBoundingClientRect();
|
||||
const newOffset = rect.top - hostRect.top;
|
||||
|
||||
scrollHost.scrollTop += (newOffset - anchor.OffsetTop);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export class InfiniteScrollAnchor {
|
||||
public Id: string;
|
||||
public OffsetTop: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace LANCommander.UI.Components.InfiniteLoader;
|
||||
|
||||
public sealed class InfiniteScrollInterop(IJSRuntime js) : IAsyncDisposable
|
||||
{
|
||||
private IJSObjectReference? _module;
|
||||
private IJSObjectReference? _observerHandle;
|
||||
|
||||
public async Task InitializeAsync(ElementReference scrollHost, ElementReference sentinel)
|
||||
{
|
||||
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
|
||||
_observerHandle = await _module.InvokeAsync<IJSObjectReference>("observeSentinel", scrollHost, sentinel);
|
||||
}
|
||||
|
||||
public async Task<object?> CaptureAnchorAsync(ElementReference scrollHost)
|
||||
{
|
||||
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
|
||||
return await _module.InvokeAsync<IJSObjectReference>("CaptureAnchor", scrollHost);
|
||||
}
|
||||
|
||||
public async Task RestoreAfterPrependAsync(ElementReference scrollHost, object? anchor)
|
||||
{
|
||||
if (anchor is null)
|
||||
return;
|
||||
|
||||
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/infiniteScroll.js");
|
||||
|
||||
await _module.InvokeVoidAsync("RestoreAfterPrepend", scrollHost, anchor);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_observerHandle is not null)
|
||||
await _observerHandle.InvokeVoidAsync("dispose");
|
||||
|
||||
if (_module is not null)
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { CaptureAnchor, ObserveSentinel, RestoreAfterPrepend } from "./Components/InfiniteLoader/InfiniteScroll";
|
||||
export { CreateUploader } from "./Components/ChunkUploader/UploaderFactory";
|
||||
export { CreateSplitPane } from "./Components/SplitPane/SplitPaneFactory";
|
||||
export { CreateTimeProvider } from "./Components/LocalTime/TimeProviderFactory";
|
||||
|
|
|
|||
777
LANCommander.UI/package-lock.json
generated
777
LANCommander.UI/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"homepage": "https://github.com/LANCommander/LANCommander#readme",
|
||||
"devDependencies": {
|
||||
"@microsoft/dotnet-js-interop": "^10.0.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"css-loader": "^7.1.2",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue