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 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorReorderExample", "BlazorReorderExample\BlazorReorderExample.csproj", "{43EF61CE-4621-48DB-8648-AA686391D140}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorReorderExample", "BlazorReorderExample\BlazorReorderExample.csproj", "{43EF61CE-4621-48DB-8648-AA686391D140}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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> <PageTitle>Blazor Reorder Example</PageTitle>
<h1>Hello, sorted world!</h1> <h1>Custom styles</h1>
Welcome to your new reorderer list. Welcome to your new reorderer list.

View file

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

View file

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

View file

@ -19,6 +19,11 @@
<span class="oi oi-plus" aria-hidden="true"></span> Styled <span class="oi oi-plus" aria-hidden="true"></span> Styled
</NavLink> </NavLink>
</div> </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> </nav>
</div> </div>

View file

@ -1,27 +1,30 @@
@typeparam TItem @typeparam TItem
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop @using Microsoft.JSInterop
@inject ReorderJsInterop<TItem> js @inject ReorderService<TItem> rs
@inject NavigationManager nav
@implements IDisposable
@if (debug) @if (Debug)
{ {
<p>@log</p> <p>@log</p>
<p>@log2</p> <p>@log2</p>
} }
@if(ghost != null) @if(rs.isDragging && elemPosition.x != -1000)
{ {
<div class="dragging item" <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 )"> 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> </div>
} }
@if (itemElem != null) @if (itemElem != null)
{ {
<div @ref="container" class="sortable"> <div class="sortable" @ref="sortable">
@foreach (var item in Items.Select((v, i) => (v, i))) @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)"> @onmousedown="async (e) => await onPress(e, item.v, item.i)">
@ChildContent(item.v) @ChildContent(item.v)
</div> </div>
@ -33,30 +36,40 @@
{ {
[Parameter, EditorRequired] public RenderFragment<TItem> ChildContent { get; set; } = null!; [Parameter, EditorRequired] public RenderFragment<TItem> ChildContent { get; set; } = null!;
[Parameter, EditorRequired] public List<TItem> Items { get; set; } = null!; [Parameter, EditorRequired] public List<TItem> Items { get; set; } = null!;
[Parameter] public bool withShadow { get; set; } = true; [Parameter] public bool Copy { get; set; } = false;
[Parameter] public bool debug { 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 bool shouldRender = true; // cancel re-rendering
private DotNetObjectReference<Reorder<TItem>>? dotNetHelper; //js-interop 2-ways private DotNetObjectReference<Reorder<TItem>>? dotNetHelper; //js-interop 2-ways
ElementReference container; Dictionary<int, ElementReference> itemElem = new();
ElementReference[]? itemElem; ElementReference? sortable;
ElementReference ghostElem; point elemPosition = new point(-1000, 0);
TItem? ghost;
point elemPosition = new point(0, 0);
point elemClickPosition = new point(0, 0);
point ghostTrans = new point(0, 0); point ghostTrans = new point(0, 0);
point clickPosition = new point(0, 0);
int elemWidth = 0; int elemWidth = 0;
int elemIndex = -1;
int newElemIndex = -1; int newElemIndex = -1;
string log = "", log2 = ""; string log = "", log2 = "";
protected override void OnParametersSet() protected override void OnInitialized()
{ {
if (Items == null) return; nav.LocationChanged += HandleLocationChanged;
}
var count = Items.Count; public void Dispose()
itemElem = new ElementReference[count]; {
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) protected override async Task OnAfterRenderAsync(bool firstRender)
@ -64,7 +77,7 @@
if (firstRender) if (firstRender)
{ {
dotNetHelper = DotNetObjectReference.Create(this); dotNetHelper = DotNetObjectReference.Create(this);
await js.initEvents(dotNetHelper); await rs.initEvents(dotNetHelper);
} }
} }
@ -72,77 +85,69 @@
{ {
if (itemElem == null) return; 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 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; var ghostElem = itemElem[index];
ghostElem = itemElem[index]; elemWidth = await rs.getWidth(ghostElem);
elemIndex = index; elemPosition = await rs.getPosition(ghostElem);
elemWidth = await js.getWidth(ghostElem); clickPosition = await rs.getPoint(m);
elemPosition = await js.getPosition(ghostElem); rs.Set(Items, item, index, clickPosition);
elemClickPosition = await js.getPoint(m);
} }
[JSInvokable] [JSInvokable]
public void onRelease(MouseEventArgs m) public void onRelease(MouseEventArgs m)
{ {
if (Items == null) return; elemPosition = new point(-1000, 0);
if (ghost != null) if (rs.isDragging && rs.originItems == Items)
{ {
ghost = default(TItem); rs.Reset();
ghostElem = default;
ghostTrans = new point(0, 0);
elemPosition = new point(0, 0);
elemClickPosition = new point(0, 0);
elemWidth = 0;
// flip // flip
if (!withShadow) if (!WithShadow)
{ {
var item = Items[elemIndex]; var item = Items[rs.elemIndex];
shouldRender = false; shouldRender = false;
Items.RemoveAt(elemIndex); Items.RemoveAt(rs.elemIndex);
Items.Insert(newElemIndex, item); Items.Insert(newElemIndex, item);
shouldRender = true; Log("mouseup");
Log("mouseup" + ghost);
} }
StateHasChanged(); shouldRender = true;
} }
StateHasChanged();
} }
[JSInvokable] [JSInvokable]
public async Task onMove(point pos) public async Task onMove(point pos)
{ {
if (Items == null || itemElem == null) return; if (itemElem == null) return;
if (ghost != null) if (rs.isDragging)
{ {
ghostTrans = new point(pos.x - elemClickPosition.x, pos.y - elemClickPosition.y); shouldRender = false;
Log($"onMove ({ghostTrans.x}, {ghostTrans.y}) {ghost}"); 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 // 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]; 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 // reorder
newElemIndex = b; newElemIndex = b;
var item = Items[elemIndex]; if (WithShadow)
var item2 = Items[newElemIndex];
// flip
if (withShadow)
{ {
shouldRender = false; rs.originItems.RemoveAt(rs.elemIndex);
Items.RemoveAt(elemIndex); Items.Insert(newElemIndex, rs.selected);
Items.Insert(newElemIndex, item); rs.elemIndex = newElemIndex;
elemIndex = newElemIndex; if (rs.originItems != Items) rs.originItems = Items;
shouldRender = true; break;
} }
Log($"Sobre {item2}");
break;
} }
} }
shouldRender = true;
StateHasChanged(); StateHasChanged();
} }
} }
@ -155,7 +160,8 @@
// checks if mouse x/y is on top of an item // checks if mouse x/y is on top of an item
async Task<bool> isOnTop(ElementReference item, point pos) 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); 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 isx = (pos.x > box.left && pos.x < (box.left + box.width));
var isy = (pos.y > box.top && pos.y < (box.top + box.height)); var isy = (pos.y > box.top && pos.y < (box.top + box.height));
@ -166,11 +172,10 @@
void Log(string info, bool sublog = false) void Log(string info, bool sublog = false)
{ {
if (debug) if (Debug)
{ {
if (sublog) log2 = info; if (sublog) log2 = info;
else log = info; else log = info;
} }
} }
} }

View file

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

View file

@ -4,14 +4,36 @@ using Microsoft.JSInterop;
namespace BlazorReorderList; namespace BlazorReorderList;
public class ReorderJsInterop<TItem> : IAsyncDisposable public class ReorderService<TItem> : IAsyncDisposable
{ {
private readonly Lazy<Task<IJSObjectReference>> moduleTask; 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>( moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/BlazorReorderList/ReorderJsInterop.js").AsTask()); "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) public async ValueTask initEvents(DotNetObjectReference<Reorder<TItem>> dotNetInstance)
@ -20,6 +42,12 @@ public class ReorderJsInterop<TItem> : IAsyncDisposable
await module.InvokeVoidAsync("initEvents", dotNetInstance); 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) public async ValueTask<int> getWidth(ElementReference el)
{ {
var module = await moduleTask.Value; var module = await moduleTask.Value;

View file

@ -2,6 +2,7 @@
var _w = window, var _w = window,
_b = document.body, _b = document.body,
_d = document.documentElement; _d = document.documentElement;
var dotNetInstance = [];
// get position of mouse/touch in relation to viewport // get position of mouse/touch in relation to viewport
export function getPoint(e) { export function getPoint(e) {
@ -13,11 +14,33 @@ export function getPoint(e) {
return { x: pointX, y: pointY }; return { x: pointX, y: pointY };
} }
export function initEvents(dotNetInstance) { // init events for each list (if the event exists it's ignored)
window.addEventListener("mousemove", (e) => dotNetInstance.invokeMethodAsync("onMove", getPoint(e)), true); export function initEvents(dotNet) {
window.addEventListener("touchmove", (e) => dotNetInstance.invokeMethodAsync("onMove", getPoint(e)), true); dotNetInstance.push(dotNet);
window.addEventListener("mouseup", (e) => dotNetInstance.invokeMethodAsync("onRelease", e), true); window.addEventListener("mousemove", onMove);
window.addEventListener("touchend", (e) => dotNetInstance.invokeMethodAsync("onRelease", e), true); 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) { export function getWidth(e) {
@ -27,5 +50,7 @@ export function getPosition(e) {
return { x: e.offsetLeft, y: e.offsetTop }; return { x: e.offsetLeft, y: e.offsetTop };
} }
export function getClientRect(e) { 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(); return e.getBoundingClientRect();
} }