From fbe3fdda4bce8866055e4942099b6063f93bd7a3 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 13 Sep 2026 17:12:33 +0200 Subject: [PATCH] Attach files dropped onto the list of attachments --- .../Components/AttachDocuments.razor.cs | 2 +- .../Dialogs/ReviewAttachmentsDialog.razor | 16 +++- .../Dialogs/ReviewAttachmentsDialog.razor.cs | 92 ++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index ababf700..3b8d3071 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -275,7 +275,7 @@ public partial class AttachDocuments : MSGComponentBase return; var previousAttachments = this.DocumentPaths.ToHashSet(); - this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths); + this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable); foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths)) ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment); diff --git a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor index bb9d8b9f..cf245df1 100644 --- a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor @@ -2,13 +2,26 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @T("Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.") + @if (this.CanAttach) + { + + @T("You can drag more files into this window to attach them right away.") + + } + -
+
@if (!this.DocumentPaths.Any()) { @@ -91,6 +104,7 @@ } }
+ diff --git a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs index aa12f128..026a11ef 100644 --- a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs @@ -16,18 +16,108 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase [Parameter] public HashSet DocumentPaths { get; set; } = new(); + /// + /// Attaches the files the user drops onto this dialog, and answers which of them it attached. + /// + /// + /// Null when this dialog only shows attachments, which is the case for a message that was + /// already sent: there is nothing left to attach to. Without this, the dialog behaves as it + /// always did and swallows every drop. + /// + [Parameter] + public Func, Task>>? AttachPaths { get; set; } + + /// + /// Decides, at the moment a drop arrives, whether attaching is possible right now. + /// + /// + /// Asked rather than passed as a value, because the answer changes while this dialog is open: + /// dropping a media file here starts a transcription, and nothing else may be attached until + /// that one is through. + /// + [Parameter] + public Func? IsAttachingUnavailable { get; set; } + [Inject] private IDialogService DialogService { get; set; } = null!; private void Close() => this.MudDialog.Close(DialogResult.Ok(this.DocumentPaths)); - public static async Task> OpenDialogAsync(IDialogService dialogService, params HashSet documentPaths) + /// Whether this dialog takes files at all, which decides what it says and shows. + private bool CanAttach => this.AttachPaths is not null; + + private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false; + + /// + /// Binds the drop zone only when there is something to attach to. An area which reports a + /// delegate claims the role of its own default target, and claiming it without being able to + /// use it would swallow drops with no reason the user could see. + /// + private EventCallback> DropCallback => this.AttachPaths is null + ? default + : EventCallback.Factory.Create>(this, this.PathsDropped); + + /// + /// Marks the list of attachments while a file hovers over this dialog, so it is visible where + /// the file would land. The frame keeps its width in both states; only its color changes, or + /// the list would jump by a few pixels with every drag. + /// + /// Whether this dialog is the target of the drop being aimed right now. + private string AttachmentListClass(bool isDropTarget) + { + if (!this.CanAttach) + return "pa-2"; + + return isDropTarget && !this.IsZoneDisabled() + ? "border-dashed border-2 rounded-lg pa-2 mud-border-primary" + : "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default"; + } + + /// + /// Attaches what the user dropped onto this dialog and answers which files that became. + /// + /// + /// Every drop takes this way, the ones aimed at the document preview above this dialog + /// included. That is why the list is refreshed here and nowhere else. + /// + /// The dropped paths, in the order the runtime delivered them. + /// The files which were attached, in the order they were dropped. + private async Task> AttachPathsAsync(List paths) + { + if (this.AttachPaths is null) + return []; + + var attached = await this.AttachPaths(paths); + this.StateHasChanged(); + + // + // The list scrolls, so a newly attached file may well sit outside the visible part of it. + // Saying so is cheaper than scrolling there, and the snackbar is skipped by the hit test, + // so it never gets in the way of the next drop. + // + if (attached.Count > 0) + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AttachFile, attached.Count is 1 + ? string.Format(T("Attached {0}."), attached[0].FileName) + : string.Format(T("Attached {0} files."), attached.Count))); + + return attached; + } + + private async Task PathsDropped(List paths) => await this.AttachPathsAsync(paths); + + public static async Task> OpenDialogAsync(IDialogService dialogService, HashSet documentPaths, Func, Task>>? attachPaths = null, Func? isAttachingUnavailable = null) { var dialogParameters = new DialogParameters { { x => x.DocumentPaths, documentPaths } }; + if (attachPaths is not null) + dialogParameters.Add(x => x.AttachPaths, attachPaths); + + if (isAttachingUnavailable is not null) + dialogParameters.Add(x => x.IsAttachingUnavailable, isAttachingUnavailable); + var dialogReference = await dialogService.ShowAsync(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled)