From 44be8ce14e851d3e4a08f1aad083260b3f0ff299 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 9 Sep 2026 16:32:26 +0200 Subject: [PATCH] Add a drop zone which reports dropped paths --- .../Components/PathDropZone.razor | 7 + .../Components/PathDropZone.razor.cs | 168 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 app/MindWork AI Studio/Components/PathDropZone.razor create mode 100644 app/MindWork AI Studio/Components/PathDropZone.razor.cs diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor b/app/MindWork AI Studio/Components/PathDropZone.razor new file mode 100644 index 00000000..fc80731a --- /dev/null +++ b/app/MindWork AI Studio/Components/PathDropZone.razor @@ -0,0 +1,7 @@ +@inherits MSGComponentBase + +
+ + @this.ChildContent + +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor.cs b/app/MindWork AI Studio/Components/PathDropZone.razor.cs new file mode 100644 index 00000000..0be8256d --- /dev/null +++ b/app/MindWork AI Studio/Components/PathDropZone.razor.cs @@ -0,0 +1,168 @@ +using AIStudio.Tools.Rust; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// A drop zone which reports the paths of whatever was dropped on it, and nothing else. +/// +/// +/// 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. +/// +public partial class PathDropZone : MSGComponentBase +{ + /// + /// The content shown inside the zone. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Reports the dropped paths, in the order the runtime delivered them. + /// + [Parameter] + public EventCallback> OnPathsDropped { get; set; } + + /// + /// On which layer to register the drop area. Higher layers have priority over lower layers. + /// + [Parameter] + public int Layer { get; set; } = DropLayers.ROOT; + + /// + /// Catch all documents that are hovered over the AI Studio window and not only over the drop zone. + /// + [Parameter] + public bool CatchAllDocuments { get; set; } + + /// + /// When true, the zone ignores drops and is not highlighted. + /// + /// + /// 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. + /// + [Parameter] + public bool Disabled { get; set; } + + [Inject] + private ILogger 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(); + } + + /// + /// Releases the drop area. + /// + 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(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(); + } +} \ No newline at end of file