Drag between lists (#1)

* * first approach with some issues
* modified service name
* added better handle of listeners
* addded new page

* * added css for empty list
* added posiblity to drag to an empty list
* corrected bug when blazor reload elements between a query to DOM
* corrected bug handling elementreferences
* corrected bug ondrop ghost still appears

* * corrected styles
* better example with three lists

Co-authored-by: Santiago Cattaneo <santiago@rd-its.com>
This commit is contained in:
elgransan 2022-04-11 13:16:52 -03:00 committed by GitHub
parent 006c3eb044
commit 55f8ab5329
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 208 additions and 77 deletions

View file

@ -5,7 +5,7 @@ VisualStudioVersion = 17.2.32314.265
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorReorderExample", "BlazorReorderExample\BlazorReorderExample.csproj", "{43EF61CE-4621-48DB-8648-AA686391D140}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorReorderList", "BlazorReorderList\BlazorReorderList.csproj", "{3DCD1165-CC1D-4015-A6D4-CE97E8A4A72B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorReorderList", "BlazorReorderList\BlazorReorderList.csproj", "{3DCD1165-CC1D-4015-A6D4-CE97E8A4A72B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View file

@ -0,0 +1,62 @@
@page "/BetweenLists"
<PageTitle>Blazor Reorder Example</PageTitle>
<h1>Drag between lists</h1>
<div class="row">
<div class="col-4">
<div class="card">
<Reorder Items="list1" TItem="ListItem">
<div class="mb-2 mx-2">
<h5>@context.title</h5>
<p>@context.details <a href="@context.url">Go</a></p>
</div>
</Reorder>
</div>
</div>
<div class="col-4">
<div class="card">
<Reorder Items="list2" TItem="ListItem">
<div class="mb-2 mx-2">
<h5>@context.title</h5>
<p>@context.details <a href="@context.url">Go</a></p>
</div>
</Reorder>
</div>
</div>
<div class="col-4">
<div class="card">
<Reorder Items="list3" TItem="ListItem">
<div class="mb-2 mx-2">
<h5>@context.title</h5>
<p>@context.details <a href="@context.url">Go</a></p>
</div>
</Reorder>
</div>
</div>
</div>
@code {
public List<ListItem> list1 = new()
{
new ListItem("Google", "https://google.com", "Again looking for a bug ..."),
new ListItem("StackOverflow", "https://stackoverflow.com", "Could be this the solution?"),
new ListItem("GitHub", "https://github.com", "Let's get awesome code"),
new ListItem("Twitter", "https://twitter.com", "I want to rest"),
new ListItem("Another", "https://another.com", "The solution must be somewhere!!!")
};
public List<ListItem> list2 = new()
{
new ListItem("Facebook", "https://facebook.com", "Meta"),
new ListItem("Instagram", "https://instagram.com", "Meta"),
new ListItem("Whatsapp", "https://web.whatsapp.com", "Meta"),
};
public List<ListItem> list3 = new()
{
new ListItem("Oculus", "https://oculus.com", "Meta"),
};
}

View file

@ -2,7 +2,7 @@
<PageTitle>Blazor Reorder Example</PageTitle>
<h1>Hello, sorted world!</h1>
<h1>Custom styles</h1>
Welcome to your new reorderer list.

View file

@ -12,6 +12,7 @@
}
::deep .dragging {
background: #ffd800;
box-shadow: 0 4px 20px #ffd800AA;
opacity: 0.8;
position: absolute;

View file

@ -7,7 +7,7 @@ builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddTransient<BlazorReorderList.ReorderJsInterop<ListItem>>();
builder.Services.AddScoped<BlazorReorderList.ReorderService<ListItem>>();
await builder.Build().RunAsync();

View file

@ -19,6 +19,11 @@
<span class="oi oi-plus" aria-hidden="true"></span> Styled
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="BetweenLists">
<span class="oi oi-plus" aria-hidden="true"></span> BetweenLists
</NavLink>
</div>
</nav>
</div>

View file

@ -1,27 +1,30 @@
@typeparam TItem
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@inject ReorderJsInterop<TItem> js
@inject ReorderService<TItem> rs
@inject NavigationManager nav
@implements IDisposable
@if (debug)
@if (Debug)
{
<p>@log</p>
<p>@log2</p>
}
@if(ghost != null)
@if(rs.isDragging && elemPosition.x != -1000)
{
<div class="dragging item"
style="width: @(elemWidth)px; left: @(elemPosition.x)px; top: @(elemPosition.y)px; transform: translateX(@(ghostTrans.x)px) translateY(@(ghostTrans.y)px )">
@ChildContent(ghost)
@ChildContent(rs.selected)
</div>
}
@if (itemElem != null)
{
<div @ref="container" class="sortable">
<div class="sortable" @ref="sortable">
@foreach (var item in Items.Select((v, i) => (v, i)))
{
<div @ref="itemElem[item.i]" class="item @(item.v.Equals(ghost) ? "active" : "")"
<div @ref="itemElem[item.i]" class="item @(item.v.Equals(rs.selected) ? "active" : "")"
@onmousedown="async (e) => await onPress(e, item.v, item.i)">
@ChildContent(item.v)
</div>
@ -33,30 +36,40 @@
{
[Parameter, EditorRequired] public RenderFragment<TItem> ChildContent { get; set; } = null!;
[Parameter, EditorRequired] public List<TItem> Items { get; set; } = null!;
[Parameter] public bool withShadow { get; set; } = true;
[Parameter] public bool debug { get; set; } = false;
[Parameter] public bool Copy { get; set; } = false;
[Parameter] public bool WithShadow { get; set; } = true;
[Parameter] public bool Debug { get; set; } = false;
private bool shouldRender = true; // cancel re-rendering
private DotNetObjectReference<Reorder<TItem>>? dotNetHelper; //js-interop 2-ways
ElementReference container;
ElementReference[]? itemElem;
ElementReference ghostElem;
TItem? ghost;
point elemPosition = new point(0, 0);
point elemClickPosition = new point(0, 0);
Dictionary<int, ElementReference> itemElem = new();
ElementReference? sortable;
point elemPosition = new point(-1000, 0);
point ghostTrans = new point(0, 0);
point clickPosition = new point(0, 0);
int elemWidth = 0;
int elemIndex = -1;
int newElemIndex = -1;
string log = "", log2 = "";
protected override void OnParametersSet()
protected override void OnInitialized()
{
if (Items == null) return;
nav.LocationChanged += HandleLocationChanged;
}
var count = Items.Count;
itemElem = new ElementReference[count];
public void Dispose()
{
nav.LocationChanged -= HandleLocationChanged;
}
public void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (dotNetHelper == null) return;
base.InvokeAsync(async () =>
{
await rs.removeEvents(dotNetHelper);
StateHasChanged();
});
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@ -64,7 +77,7 @@
if (firstRender)
{
dotNetHelper = DotNetObjectReference.Create(this);
await js.initEvents(dotNetHelper);
await rs.initEvents(dotNetHelper);
}
}
@ -72,77 +85,69 @@
{
if (itemElem == null) return;
shouldRender = false; // Because the method triggers re-render, the click propagation is canceled, if you have a link/or any click event inside it's going to stop working
ghost = item;
ghostElem = itemElem[index];
elemIndex = index;
elemWidth = await js.getWidth(ghostElem);
elemPosition = await js.getPosition(ghostElem);
elemClickPosition = await js.getPoint(m);
var ghostElem = itemElem[index];
elemWidth = await rs.getWidth(ghostElem);
elemPosition = await rs.getPosition(ghostElem);
clickPosition = await rs.getPoint(m);
rs.Set(Items, item, index, clickPosition);
}
[JSInvokable]
public void onRelease(MouseEventArgs m)
{
if (Items == null) return;
if (ghost != null)
elemPosition = new point(-1000, 0);
if (rs.isDragging && rs.originItems == Items)
{
ghost = default(TItem);
ghostElem = default;
ghostTrans = new point(0, 0);
elemPosition = new point(0, 0);
elemClickPosition = new point(0, 0);
elemWidth = 0;
rs.Reset();
// flip
if (!withShadow)
if (!WithShadow)
{
var item = Items[elemIndex];
var item = Items[rs.elemIndex];
shouldRender = false;
Items.RemoveAt(elemIndex);
Items.RemoveAt(rs.elemIndex);
Items.Insert(newElemIndex, item);
Log("mouseup");
}
shouldRender = true;
Log("mouseup" + ghost);
}
StateHasChanged();
}
}
[JSInvokable]
public async Task onMove(point pos)
{
if (Items == null || itemElem == null) return;
if (ghost != null)
if (itemElem == null) return;
if (rs.isDragging)
{
ghostTrans = new point(pos.x - elemClickPosition.x, pos.y - elemClickPosition.y);
Log($"onMove ({ghostTrans.x}, {ghostTrans.y}) {ghost}");
shouldRender = false;
ghostTrans = new point(pos.x - rs.elemClickPosition.x, pos.y - rs.elemClickPosition.y);
Log($"onMove ({elemPosition.x}, {elemPosition.y})");
// check if current drag item is over another item and swap places
for(var b = 0; b < itemElem.Length; ++b)
for (var b = 0; b < itemElem.Count; ++b)
{
if (b == elemIndex) continue;
var subItem = itemElem[b];
if (rs.originItems == Items && b == rs.elemIndex) continue;
if (await isOnTop(subItem, pos))
// If is on top of an element or if top top of a empty list -> confirm drag
if (await isOnTop(subItem, pos) ||
Items.Count == 0 && await isOnTop((ElementReference) sortable, pos))
{
// reorder
newElemIndex = b;
var item = Items[elemIndex];
var item2 = Items[newElemIndex];
// flip
if (withShadow)
if (WithShadow)
{
shouldRender = false;
Items.RemoveAt(elemIndex);
Items.Insert(newElemIndex, item);
elemIndex = newElemIndex;
shouldRender = true;
}
Log($"Sobre {item2}");
rs.originItems.RemoveAt(rs.elemIndex);
Items.Insert(newElemIndex, rs.selected);
rs.elemIndex = newElemIndex;
if (rs.originItems != Items) rs.originItems = Items;
break;
}
}
}
shouldRender = true;
StateHasChanged();
}
}
@ -155,7 +160,8 @@
// checks if mouse x/y is on top of an item
async Task<bool> isOnTop(ElementReference item, point pos)
{
var box = await js.getClientRect(item);
var box = await rs.getClientRect(item);
if (box.width < 0) return false;
Log($"\npos x: {pos.x}, y: {pos.y}\n item: left:{box.left}, width:{box.width}\nitem: top:{box.top}, height:{box.height}", true);
var isx = (pos.x > box.left && pos.x < (box.left + box.width));
var isy = (pos.y > box.top && pos.y < (box.top + box.height));
@ -166,11 +172,10 @@
void Log(string info, bool sublog = false)
{
if (debug)
if (Debug)
{
if (sublog) log2 = info;
else log = info;
}
}
}

View file

@ -1,3 +1,7 @@
.sortable {
min-height: 10px;
}
.item {
cursor: move;
user-select: none;
@ -12,6 +16,7 @@
}
.dragging {
background: #e0ffff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
opacity: 0.8;
position: absolute;

View file

@ -4,22 +4,50 @@ using Microsoft.JSInterop;
namespace BlazorReorderList;
public class ReorderJsInterop<TItem> : IAsyncDisposable
public class ReorderService<TItem> : IAsyncDisposable
{
private readonly Lazy<Task<IJSObjectReference>> moduleTask;
public List<TItem>? originItems;
public int elemIndex = -1;
public TItem selected = default(TItem);
public point elemClickPosition = new point(0, 0);
public bool isDragging = false;
public ReorderJsInterop(IJSRuntime jsRuntime)
public ReorderService(IJSRuntime jsRuntime)
{
moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/BlazorReorderList/ReorderJsInterop.js").AsTask());
}
public void Set(List<TItem> list, TItem item, int index, point clickPoint)
{
isDragging = true;
originItems = list;
selected = item;
elemIndex = index;
elemClickPosition = clickPoint;
}
public void Reset()
{
isDragging = false;
originItems = default(List<TItem>);
selected = default(TItem);
elemClickPosition = new point(0, 0);
}
public async ValueTask initEvents(DotNetObjectReference<Reorder<TItem>> dotNetInstance)
{
var module = await moduleTask.Value;
await module.InvokeVoidAsync("initEvents", dotNetInstance);
}
public async ValueTask removeEvents(DotNetObjectReference<Reorder<TItem>> dotNetInstance)
{
var module = await moduleTask.Value;
await module.InvokeVoidAsync("removeEvents", dotNetInstance);
}
public async ValueTask<int> getWidth(ElementReference el)
{
var module = await moduleTask.Value;

View file

@ -2,6 +2,7 @@
var _w = window,
_b = document.body,
_d = document.documentElement;
var dotNetInstance = [];
// get position of mouse/touch in relation to viewport
export function getPoint(e) {
@ -13,11 +14,33 @@ export function getPoint(e) {
return { x: pointX, y: pointY };
}
export function initEvents(dotNetInstance) {
window.addEventListener("mousemove", (e) => dotNetInstance.invokeMethodAsync("onMove", getPoint(e)), true);
window.addEventListener("touchmove", (e) => dotNetInstance.invokeMethodAsync("onMove", getPoint(e)), true);
window.addEventListener("mouseup", (e) => dotNetInstance.invokeMethodAsync("onRelease", e), true);
window.addEventListener("touchend", (e) => dotNetInstance.invokeMethodAsync("onRelease", e), true);
// init events for each list (if the event exists it's ignored)
export function initEvents(dotNet) {
dotNetInstance.push(dotNet);
window.addEventListener("mousemove", onMove);
window.addEventListener("touchmove", onMove);
window.addEventListener("mouseup", onRelease);
window.addEventListener("touchend", onRelease);
}
// only remove from the collection
export function removeEvents(dotNet) {
dotNetInstance = dotNetInstance.filter(x => x._id !== dotNet._id);
}
// only invoke events form the collection
function onMove(e) {
var point = getPoint(e);
for (var i = 0; i < dotNetInstance.length; i++) {
dotNetInstance[i].invokeMethodAsync("onMove", point);
}
}
// only invoke events form the collection
export function onRelease(e) {
for (var i = 0; i < dotNetInstance.length; i++) {
dotNetInstance[i].invokeMethodAsync("onRelease", e);
}
}
export function getWidth(e) {
@ -27,5 +50,7 @@ export function getPosition(e) {
return { x: e.offsetLeft, y: e.offsetTop };
}
export function getClientRect(e) {
// blazor reset elements id in the middle of the query, so this invalidate the query and prevents errors
if (e === null || e === undefined) return { width: -1 };
return e.getBoundingClientRect();
}