Add a drop zone which reports dropped paths

This commit is contained in:
Thorsten Sommer 2026-09-09 16:32:26 +02:00
parent b0b30b0f9b
commit 44be8ce14e
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
2 changed files with 175 additions and 0 deletions

View File

@ -0,0 +1,7 @@
@inherits MSGComponentBase
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
<MudPaper Outlined="@true" Class="@this.dragClass">
@this.ChildContent
</MudPaper>
</div>

View File

@ -0,0 +1,168 @@
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A drop zone which reports the paths of whatever was dropped on it, and nothing else.
/// </summary>
/// <remarks>
/// Dropping is a native matter in AI Studio: the Tauri runtime reports real paths, which is why
/// this zone can hand out folders just as well as files. What those paths mean is the consumer's
/// business — this component reads no content and does not care whether a path leads to a file or
/// to a folder.
/// </remarks>
public partial class PathDropZone : MSGComponentBase
{
/// <summary>
/// The content shown inside the zone.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Reports the dropped paths, in the order the runtime delivered them.
/// </summary>
[Parameter]
public EventCallback<List<string>> OnPathsDropped { get; set; }
/// <summary>
/// On which layer to register the drop area. Higher layers have priority over lower layers.
/// </summary>
[Parameter]
public int Layer { get; set; } = DropLayers.ROOT;
/// <summary>
/// Catch all documents that are hovered over the AI Studio window and not only over the drop zone.
/// </summary>
[Parameter]
public bool CatchAllDocuments { get; set; }
/// <summary>
/// When true, the zone ignores drops and is not highlighted.
/// </summary>
/// <remarks>
/// The drop area stays registered nevertheless. Releasing it during the lifetime of the
/// component would lower the count of every zone below this one, and those zones would then
/// catch files while this one is still on screen.
/// </remarks>
[Parameter]
public bool Disabled { get; set; }
[Inject]
private ILogger<PathDropZone> Logger { get; init; } = null!;
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full";
private string dragClass = DEFAULT_DRAG_CLASS;
private uint numDropAreasAboveThis;
private bool isComponentHovered;
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer);
await base.OnInitializedAsync();
}
/// <summary>
/// Releases the drop area.
/// </summary>
protected override void DisposeResources()
{
// Without this, drop areas below this one would count this component forever and would
// stop catching dropped files:
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(PathDropZone)}: releasing the drop area");
base.DisposeResources();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
// A disabled zone takes no files. It keeps track of the zones above it, though, because
// those come and go while this one is disabled:
if (this.Disabled && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
return;
switch (triggeredEvent)
{
case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer)
{
this.numDropAreasAboveThis++;
this.ClearDragClass();
}
break;
}
case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this:
{
if(data is int layer && layer > this.Layer && this.numDropAreasAboveThis > 0)
this.numDropAreasAboveThis--;
break;
}
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }:
if(!this.CanCatchDroppedPath())
return;
this.SetDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }:
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }:
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var paths }:
if(!this.CanCatchDroppedPath())
return;
this.Logger.LogDebug("The path drop zone on layer {Layer} caught {Count} path(s).", this.Layer, paths.Count);
await this.OnPathsDropped.InvokeAsync(paths);
this.ClearDragClass();
this.StateHasChanged();
break;
}
}
#endregion
private bool CanCatchDroppedPath() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);
private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2";
private void ClearDragClass() => this.dragClass = DEFAULT_DRAG_CLASS;
private void OnMouseEnter(EventArgs _)
{
if(this.Disabled || this.numDropAreasAboveThis > 0)
return;
// The webview gets no DOM drag events while a native drag is in progress, so the mouse is
// what tells us whether the user is aiming at this zone:
this.isComponentHovered = true;
this.SetDragClass();
this.StateHasChanged();
}
private void OnMouseLeave(EventArgs _)
{
if(this.Disabled)
return;
this.isComponentHovered = false;
this.ClearDragClass();
this.StateHasChanged();
}
}