diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 97319b33..e694ebd9 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -2,7 +2,9 @@ @inherits AssistantLowerBase @typeparam TSettings -
+@* Every assistant is a drop area: a file dropped anywhere inside it lands on the assistant's + default zone, while a file dropped on one of its specific zones lands there. *@ + @@ -186,4 +188,4 @@ -
+ \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor index 7e9429d9..b86ba94b 100644 --- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor +++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor @@ -7,7 +7,7 @@ @T("Input") - + @@ -44,7 +44,7 @@ @if (this.promptSource is BatchProcessingPromptSource.FREE_PROMPT) { - + @@ -52,7 +52,7 @@ } else if (this.promptSource is BatchProcessingPromptSource.FILE_IMPORT) { - + @if (!string.IsNullOrWhiteSpace(this.promptFilePath)) { @@ -161,7 +161,7 @@ else } } - + @T("We always write a semicolon-separated log named log.csv, which lists every document with its processing time, the model, the status, and the details of any error. When you start another run on the same output folder, we ask you whether to continue that run: documents which failed or are missing in the log are then processed again. When no output folder is selected, everything is written to the subfolder 'ai-results' within the input folder.") diff --git a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor index 5c6edaf4..4b7f965e 100644 --- a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor +++ b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor @@ -7,7 +7,7 @@ @if (this.step is BuilderStep.DESCRIBE) { - + @* This switch chooses between the two kinds of assistant the Builder can create, so it stays diff --git a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor index e2d3e719..d6fb167d 100644 --- a/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor +++ b/app/MindWork AI Studio/Assistants/Coding/AssistantCoding.razor @@ -6,7 +6,7 @@ @T("You can attach source files as optional context for your coding question.")
- +
diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor index 99641135..2e1dcfd8 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor @@ -74,7 +74,7 @@ else @T("Documents for the analysis") - + } else @@ -166,7 +166,7 @@ else @T("Documents for the analysis") - + diff --git a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor index ba131269..2f6ab00a 100644 --- a/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor +++ b/app/MindWork AI Studio/Assistants/Dynamic/AssistantDynamic.razor @@ -153,7 +153,7 @@ else { var fileState = this.assistantState.FileContent[fileContent.Name];
- +
} break; @@ -169,7 +169,6 @@ else }
- + diff --git a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor index d98e8645..873a1126 100644 --- a/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor +++ b/app/MindWork AI Studio/Assistants/GrammarSpelling/AssistantGrammarSpelling.razor @@ -1,7 +1,7 @@ @attribute [Route(Routes.ASSISTANT_GRAMMAR_SPELLING)] @inherits AssistantBaseCore - + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor index fb7261e7..46aeb900 100644 --- a/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor +++ b/app/MindWork AI Studio/Assistants/LegalCheck/AssistantLegalCheck.razor @@ -6,7 +6,7 @@ } - + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor index 4a738ef7..78e32699 100644 --- a/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor +++ b/app/MindWork AI Studio/Assistants/MyTasks/AssistantMyTasks.razor @@ -8,7 +8,7 @@ @T("You can enter text, attach one or more documents, or use both. At least one input is required.")
- +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor index b2c1d3b1..8bab01a7 100644 --- a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor +++ b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor @@ -95,7 +95,6 @@ @if (this.useCustomPromptGuide) { - + diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor index e451ab3d..2d1d56de 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor @@ -8,7 +8,7 @@ @T("Attach documents") - + @T("Details about the desired presentation") diff --git a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor index 42fde1aa..862bb856 100644 --- a/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor +++ b/app/MindWork AI Studio/Assistants/TextSummarizer/AssistantTextSummarizer.razor @@ -6,7 +6,7 @@ } - + diff --git a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor index 305be9b6..525aa2ff 100644 --- a/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor +++ b/app/MindWork AI Studio/Assistants/Translation/AssistantTranslation.razor @@ -6,7 +6,7 @@ } - + @if (this.liveTranslation) diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor index cfcc28dc..a601ceb5 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor @@ -122,7 +122,6 @@ @T("Source material") @T("Documents, spreadsheets, images, audio, and video are considered as source context.") @T("Visual assets") @T("PNG, JPEG, and WebP assets are analyzed and must appear visibly in the briefing.") -
+
@if (this.isDraggingOver) { } -
+
@foreach (var fileAttachment in this.DocumentPaths) { diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index e3e3035a..32f2f20d 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -24,18 +24,6 @@ public partial class AttachDocuments : MSGComponentBase [Parameter] public string Name { get; set; } = string.Empty; - /// - /// On which layer to register the drop area. Higher layers have priority over lower layers. - /// - [Parameter] - public int Layer { get; set; } - - /// - /// When true, pause catching dropped files. Default is false. - /// - [Parameter] - public bool PauseCatchingDrops { get; set; } - [Parameter] public HashSet DocumentPaths { get; set; } = []; @@ -46,11 +34,23 @@ public partial class AttachDocuments : MSGComponentBase public Func, Task> OnChange { get; set; } = _ => Task.CompletedTask; /// - /// Catch all documents that are hovered over the AI Studio window and not only over the drop zone. + /// Makes this component the default target of its area, meaning of its page, assistant, or + /// dialog: it then also takes the drops which land anywhere in that area without hitting a zone + /// of their own. /// + /// + /// Only one zone per area can hold that role, and if several ask for it, the first one in the + /// markup gets it. + /// [Parameter] public bool CatchAllDocuments { get; set; } + /// + /// The area this component lives in, if it lives in one at all. + /// + [CascadingParameter] + private DropZoneScopeState? Scope { get; set; } + [Parameter] public bool UseSmallForm { get; set; } @@ -105,8 +105,9 @@ public partial class AttachDocuments : MSGComponentBase private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly string DROP_FILES_HERE_TEXT = TB("Drop files here to attach them."); - private uint numDropAreasAboveThis; - private bool isComponentHovered; + private readonly string dropZoneId = $"attach-documents-{Guid.NewGuid():N}"; + + private bool isDefaultZone; private bool isDraggingOver; private bool isFileDialogOpen; private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null @@ -122,10 +123,9 @@ public partial class AttachDocuments : MSGComponentBase protected override async Task OnInitializedAsync() { this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; - this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]); + this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + this.ClaimDefaultZoneRole(); - // Register this drop area: - await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, this.Layer); await base.OnInitializedAsync(); } @@ -223,93 +223,37 @@ public partial class AttachDocuments : MSGComponentBase { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; - // Release the drop area. 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(AttachDocuments)}: releasing the drop area"); + // Hand the role of the default target back to the area: + if (this.isDefaultZone) + this.Scope?.ReleaseDefaultZone(this); base.DisposeResources(); } protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default { - if (this.IsUnavailable && 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.PauseCatchingDrops = true; - } - + case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: + this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); break; - } - case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this: - { - if(data is int layer && layer > this.Layer) - { - if(this.numDropAreasAboveThis > 0) - this.numDropAreasAboveThis--; + case Event.PATHS_DROPPED when data is DroppedPaths dropped: + // Whoever the drop was meant for, the drag is over and no zone stays highlighted: + this.ApplyHighlight(false); - if(this.numDropAreasAboveThis is 0) - this.PauseCatchingDrops = false; - } - - break; - } - - case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }: - if(this.PauseCatchingDrops) + if (!this.IsThisZone(dropped.ZoneId)) return; - if(!this.isComponentHovered && !this.CatchAllDocuments) + if (this.IsUnavailable) { - this.Logger.LogDebug("Attach documents component '{Name}' is not hovered, ignoring file drop hovered event.", this.Name); + this.Logger.LogDebug("The attachment zone '{Name}' is unavailable and swallowed {Count} dropped path(s).", this.Name, dropped.Paths.Count); return; } - this.isDraggingOver = true; - this.SetDragClass(); - this.StateHasChanged(); - break; - - case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }: - if(this.PauseCatchingDrops) - return; - - this.isDraggingOver = false; - this.StateHasChanged(); - break; - - case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }: - if(this.PauseCatchingDrops) - return; - - this.isDraggingOver = false; - 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.PauseCatchingDrops) - return; - - if(!this.isComponentHovered && !this.CatchAllDocuments) - { - this.Logger.LogDebug("Attach documents component '{Name}' is not hovered, ignoring file drop dropped event.", this.Name); - return; - } - - await this.AddFileBatchAsync(paths); + await this.AddFileBatchAsync(dropped.Paths); await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.OnChange(this.DocumentPaths); - this.isDraggingOver = false; - this.ClearDragClass(); this.StateHasChanged(); break; } @@ -317,6 +261,48 @@ public partial class AttachDocuments : MSGComponentBase #endregion + /// + /// Asks the area for the role of its default target, if this component wants it. + /// + private void ClaimDefaultZoneRole() + { + if (!this.CatchAllDocuments || this.Scope is null) + return; + + this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); + if (!this.isDefaultZone) + this.Logger.LogDebug("The attachment zone '{Name}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.Name); + } + + /// + /// Decides whether the named zone is this one. + /// + /// + /// The area counts as this zone as long as this zone is its default target. That is the whole + /// mechanism behind dropping anywhere in the chat and still landing on the composer. + /// + /// The ID the hit test reported, or null when it hit nothing. + private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); + + /// + /// Highlights the zone, or takes the highlight away. + /// + /// + /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per + /// second, and without it every one of them would render every zone on the page anew. In the + /// small form the highlight also swaps the markup, so this keeps the composer from flickering. + /// + private void ApplyHighlight(bool shouldBeHighlighted) + { + var highlighted = shouldBeHighlighted && !this.IsUnavailable; + if (highlighted == this.isDraggingOver) + return; + + this.isDraggingOver = highlighted; + this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-4" : DEFAULT_DRAG_CLASS; + this.StateHasChanged(); + } + private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-4 mt-4 mud-width-full mud-height-full"; private string dragClass = DEFAULT_DRAG_CLASS; @@ -370,32 +356,6 @@ public partial class AttachDocuments : MSGComponentBase await this.OnChange(this.DocumentPaths); } - private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-4"; - - private void ClearDragClass() => this.dragClass = DEFAULT_DRAG_CLASS; - - private void OnMouseEnter(EventArgs _) - { - if(this.IsUnavailable || this.PauseCatchingDrops) - return; - - this.Logger.LogDebug("Attach documents component '{Name}' is hovered.", this.Name); - this.isComponentHovered = true; - this.SetDragClass(); - this.StateHasChanged(); - } - - private void OnMouseLeave(EventArgs _) - { - if(this.IsUnavailable || this.PauseCatchingDrops) - return; - - this.Logger.LogDebug("Attach documents component '{Name}' is no longer hovered.", this.Name); - this.isComponentHovered = false; - this.ClearDragClass(); - this.StateHasChanged(); - } - private async Task RemoveDocument(FileAttachment fileAttachment) { if (this.IsUnavailable) diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 740bcde3..ad7bd9e0 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -104,7 +104,7 @@ } - + diff --git a/app/MindWork AI Studio/Components/DropZoneScope.razor b/app/MindWork AI Studio/Components/DropZoneScope.razor new file mode 100644 index 00000000..b0f3f597 --- /dev/null +++ b/app/MindWork AI Studio/Components/DropZoneScope.razor @@ -0,0 +1,8 @@ +@* One element, and the content immediately inside it. Layouts hang child selectors on this + element, for instance app.css does on .inner-scrolling-context, so an extra level here would + break them. The cascading value renders no element of its own. *@ +
+ + @this.ChildContent + +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DropZoneScope.razor.cs b/app/MindWork AI Studio/Components/DropZoneScope.razor.cs new file mode 100644 index 00000000..0ed4a75f --- /dev/null +++ b/app/MindWork AI Studio/Components/DropZoneScope.razor.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// Marks an area whose drops end up at its default target when they hit no specific zone. +/// +/// +/// +/// This is how the habitual behaviour survives the move to hit testing: a file dropped anywhere in +/// the chat, in an assistant, or in a dialog still arrives where it used to, while a file dropped +/// on a specific zone now arrives exactly there. No code decides between the two -- the browser +/// does, because the specific zone lies deeper in the DOM than the area around it, and the hit test +/// resolves from the inside out. +/// +/// +/// The component replaces an existing element rather than adding one: it takes the class and the +/// style of the element it stands in for. Where the areas already had a wrapper, which is the case +/// for every page and every assistant, nothing about the layout changes. +/// +/// +public partial class DropZoneScope : ComponentBase +{ + /// + /// The content of the area. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// The CSS classes of the element this scope renders. + /// + [Parameter] + public string Class { get; set; } = string.Empty; + + /// + /// The inline style of the element this scope renders. + /// + [Parameter] + public string Style { get; set; } = string.Empty; + + /// + /// The state to cascade, for hosts which want to be the default target of their own area. + /// + /// + /// A host cannot read what it cascades itself, so a page which is its own drop target has to own + /// the state instead. The plugins page is such a case: it accepts an archive anywhere on it and + /// has no inner zone to hand the role to. Everybody else leaves this alone and lets the scope + /// keep its own state. + /// + [Parameter] + public DropZoneScopeState? State { get; set; } + + private readonly DropZoneScopeState ownState = new($"drop-zone-scope-{Guid.NewGuid():N}"); + + private DropZoneScopeState EffectiveState => this.State ?? this.ownState; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor b/app/MindWork AI Studio/Components/PathDropZone.razor index fc80731a..d5c917b8 100644 --- a/app/MindWork AI Studio/Components/PathDropZone.razor +++ b/app/MindWork AI Studio/Components/PathDropZone.razor @@ -1,6 +1,6 @@ @inherits MSGComponentBase -
+
@this.ChildContent diff --git a/app/MindWork AI Studio/Components/PathDropZone.razor.cs b/app/MindWork AI Studio/Components/PathDropZone.razor.cs index 09d6b0b9..fa86b320 100644 --- a/app/MindWork AI Studio/Components/PathDropZone.razor.cs +++ b/app/MindWork AI Studio/Components/PathDropZone.razor.cs @@ -1,5 +1,3 @@ -using AIStudio.Tools.Rust; - using Microsoft.AspNetCore.Components; namespace AIStudio.Components; @@ -28,22 +26,13 @@ public partial class PathDropZone : MSGComponentBase 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. + /// Makes this zone the default target of its area, meaning of its page, assistant, or dialog. /// /// - /// Practically every zone needs this today. Hovering is detected through mouse events, and no - /// webview delivers those while a native drag is in progress, so a zone without this flag - /// hardly ever catches anything. The consequence is that two zones of the same layer cannot be - /// told apart: the one carrying this flag takes every drop, including the ones meant for the - /// other. A page may therefore hold only one zone per layer. Lifting that limit needs the - /// cursor position, which the runtime receives from Tauri and currently discards in - /// app_window.rs. + /// A drop aimed at this zone arrives here in any case. What this flag decides is the fate of the + /// drops aimed anywhere else in the surrounding area which hit no zone of their own: with the + /// flag, they arrive here as well. Only one zone per area can hold that role, and if several ask + /// for it, the first one in the markup gets it. /// [Parameter] public bool CatchAllDocuments { get; set; } @@ -52,127 +41,118 @@ public partial class PathDropZone : MSGComponentBase /// 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. + /// It keeps its ID in the DOM nevertheless and therefore swallows the drops aimed at it. That is + /// what the pointer says: it rests on a switched-off field, so nothing happens. Letting the drop + /// fall through to the area behind it would deliver the files somewhere else entirely. /// [Parameter] public bool Disabled { get; set; } + /// + /// The area this zone lives in, if it lives in one at all. + /// + [CascadingParameter] + private DropZoneScopeState? Scope { 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 readonly string dropZoneId = $"path-drop-zone-{Guid.NewGuid():N}"; + private string dragClass = DEFAULT_DRAG_CLASS; - private uint numDropAreasAboveThis; - private bool isComponentHovered; + private bool isDefaultZone; + private bool isHighlighted; #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); + this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + this.ClaimDefaultZoneRole(); await base.OnInitializedAsync(); } /// - /// Releases the drop area. + /// Hands the role of the default target back to the 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"); + if (this.isDefaultZone) + this.Scope?.ReleaseDefaultZone(this); 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) + case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: + this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); + break; + + case Event.PATHS_DROPPED when data is DroppedPaths dropped: + // Whoever the drop was meant for, the drag is over and no zone stays highlighted: + this.ApplyHighlight(false); + + if (!this.IsThisZone(dropped.ZoneId)) + return; + + if (this.Disabled) { - this.numDropAreasAboveThis++; - this.ClearDragClass(); + this.Logger.LogDebug("The path drop zone '{ZoneId}' is disabled and swallowed {Count} dropped path(s).", this.dropZoneId, dropped.Paths.Count); + return; } - 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(); + this.Logger.LogDebug("The path drop zone '{ZoneId}' caught {Count} path(s).", this.dropZoneId, dropped.Paths.Count); + await this.OnPathsDropped.InvokeAsync(dropped.Paths); 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 _) + /// + /// Asks the area for the role of its default target, if this zone wants it. + /// + private void ClaimDefaultZoneRole() { - if(this.Disabled || this.numDropAreasAboveThis > 0) + if (!this.CatchAllDocuments || this.Scope is null) return; - // A native drag delivers no DOM events at all, mouse events included. This fires before a - // drag begins, while the pointer still moves freely, which makes it a hint about where the - // user is aiming rather than a reliable signal. See the remarks on CatchAllDocuments: - this.isComponentHovered = true; - this.SetDragClass(); - this.StateHasChanged(); + this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); + if (!this.isDefaultZone) + this.Logger.LogDebug("The path drop zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.dropZoneId); } - private void OnMouseLeave(EventArgs _) + /// + /// Decides whether the named zone is this one. + /// + /// + /// The area counts as this zone as long as this zone is its default target. That is the whole + /// mechanism behind dropping anywhere in a page and still landing here. + /// + /// The ID the hit test reported, or null when it hit nothing. + private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); + + /// + /// Highlights the zone, or takes the highlight away. + /// + /// + /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per + /// second, and without it every one of them would render every zone on the page anew. + /// + private void ApplyHighlight(bool shouldBeHighlighted) { - if(this.Disabled) + var highlighted = shouldBeHighlighted && !this.Disabled; + if (highlighted == this.isHighlighted) return; - this.isComponentHovered = false; - this.ClearDragClass(); + this.isHighlighted = highlighted; + this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2" : DEFAULT_DRAG_CLASS; this.StateHasChanged(); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor b/app/MindWork AI Studio/Components/ReadFileContent.razor index 3b34fe5e..a2b7f3db 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor @@ -2,7 +2,7 @@ @if (this.EnableDragDrop) { -
+
@if (this.ShowAttachedDocumentState && this.hasLoadedFileContent) diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 0895e133..20218590 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -46,17 +46,23 @@ public partial class ReadFileContent : MSGComponentBase public bool EnableDragDrop { get; set; } /// - /// On which layer to register the drop area. Higher layers have priority over lower layers. - /// - [Parameter] - public int Layer { get; set; } - - /// - /// Catch all documents that are hovered over the AI Studio window and not only over the drop zone. + /// Makes this component the default target of its area, meaning of its page, assistant, or + /// dialog: it then also takes the drops which land anywhere in that area without hitting a zone + /// of their own. /// + /// + /// Only one zone per area can hold that role, and if several ask for it, the first one in the + /// markup gets it. The flag has no effect without drag and drop being enabled. + /// [Parameter] public bool CatchAllDocuments { get; set; } + /// + /// The area this component lives in, if it lives in one at all. + /// + [CascadingParameter] + private DropZoneScopeState? Scope { get; set; } + /// /// Optionally restricts the file types offered by the native file picker /// and accepted by this component. @@ -81,10 +87,12 @@ public partial class ReadFileContent : MSGComponentBase private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full"; + private readonly string dropZoneId = $"read-file-content-{Guid.NewGuid():N}"; + private string ButtonText => string.IsNullOrWhiteSpace(this.Text) ? T("Use file content as input") : this.Text; private string dragClass = DEFAULT_DRAG_CLASS; - private uint numDropAreasAboveThis; - private bool isComponentHovered; + private bool isDefaultZone; + private bool isHighlighted; private bool isFileDialogOpen; private bool hasLoadedFileContent; private string loadedFileName = string.Empty; @@ -120,8 +128,8 @@ public partial class ReadFileContent : MSGComponentBase this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; if (this.EnableDragDrop) { - 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); + this.ApplyFilters([], [ Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); + this.ClaimDefaultZoneRole(); } await base.OnInitializedAsync(); @@ -192,10 +200,9 @@ public partial class ReadFileContent : MSGComponentBase { this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; - // Release the drop area. Without this, drop areas below this one would count this component - // forever and would stop catching dropped files: - if (this.EnableDragDrop) - this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(ReadFileContent)}: releasing the drop area"); + // Hand the role of the default target back to the area: + if (this.isDefaultZone) + this.Scope?.ReleaseDefaultZone(this); base.DisposeResources(); } @@ -205,57 +212,73 @@ public partial class ReadFileContent : MSGComponentBase if (!this.EnableDragDrop) return; - if (this.IsUnavailable && 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) + case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: + this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); + break; + + case Event.PATHS_DROPPED when data is DroppedPaths dropped: + // Whoever the drop was meant for, the drag is over and no zone stays highlighted: + this.ApplyHighlight(false); + + if (!this.IsThisZone(dropped.ZoneId)) + return; + + if (this.IsUnavailable) { - this.numDropAreasAboveThis++; - this.ClearDragClass(); + this.Logger.LogDebug("The file zone '{ZoneId}' is unavailable and swallowed {Count} dropped path(s).", this.dropZoneId, dropped.Paths.Count); + return; } - 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.CanCatchDroppedFile()) - 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.CanCatchDroppedFile()) - return; - - await this.LoadFirstValidFile(paths); - this.ClearDragClass(); + await this.LoadFirstValidFile(dropped.Paths); this.StateHasChanged(); break; } } #endregion + + /// + /// Asks the area for the role of its default target, if this component wants it. + /// + private void ClaimDefaultZoneRole() + { + if (!this.CatchAllDocuments || this.Scope is null) + return; + + this.isDefaultZone = this.Scope.TryBecomeDefaultZone(this); + if (!this.isDefaultZone) + this.Logger.LogDebug("The file zone '{ZoneId}' asked to be the default target of its area, which another zone already is. It now takes only the drops aimed at itself.", this.dropZoneId); + } + + /// + /// Decides whether the named zone is this one. + /// + /// + /// The area counts as this zone as long as this zone is its default target. That is the whole + /// mechanism behind dropping anywhere in an assistant and still landing here. + /// + /// The ID the hit test reported, or null when it hit nothing. + private bool IsThisZone(string? zoneId) => zoneId is not null && (zoneId == this.dropZoneId || (this.isDefaultZone && zoneId == this.Scope?.ScopeId)); + + /// + /// Highlights the zone, or takes the highlight away. + /// + /// + /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per + /// second, and without it every one of them would render every zone on the page anew. + /// + private void ApplyHighlight(bool shouldBeHighlighted) + { + var highlighted = shouldBeHighlighted && !this.IsUnavailable; + if (highlighted == this.isHighlighted) + return; + + this.isHighlighted = highlighted; + this.dragClass = highlighted ? $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2" : DEFAULT_DRAG_CLASS; + this.StateHasChanged(); + } private async Task SelectFile() { @@ -417,31 +440,4 @@ public partial class ReadFileContent : MSGComponentBase return string.Format(this.T("Attached file '{0}'."), this.loadedFileName); } - private bool CanCatchDroppedFile() => 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.IsUnavailable || this.numDropAreasAboveThis > 0) - return; - - this.Logger.LogDebug("Read file content component is hovered."); - this.isComponentHovered = true; - this.SetDragClass(); - this.StateHasChanged(); - } - - private void OnMouseLeave(EventArgs _) - { - if(this.IsUnavailable) - return; - - this.Logger.LogDebug("Read file content component is no longer hovered."); - this.isComponentHovered = false; - this.ClearDragClass(); - this.StateHasChanged(); - } } diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor b/app/MindWork AI Studio/Components/SelectDirectory.razor index b078d474..73731fe3 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor @@ -2,7 +2,7 @@ @if (this.EnableDragDrop) { - + @this.Picker @T("You can also drag & drop the folder here.") diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs index 883d5277..f66c5e43 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs @@ -34,13 +34,9 @@ public partial class SelectDirectory : MSGComponentBase public bool EnableDragDrop { 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. + /// Makes this component the default target of its area, meaning of its page, assistant, or + /// dialog: it then also takes the drops which land anywhere in that area without hitting a zone + /// of their own. /// [Parameter] public bool CatchAllDocuments { get; set; } diff --git a/app/MindWork AI Studio/Components/SelectFile.razor b/app/MindWork AI Studio/Components/SelectFile.razor index 4df232b8..1450f064 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor +++ b/app/MindWork AI Studio/Components/SelectFile.razor @@ -2,7 +2,7 @@ @if (this.EnableDragDrop) { - + @this.Picker @T("You can also drag & drop the file here.") diff --git a/app/MindWork AI Studio/Components/SelectFile.razor.cs b/app/MindWork AI Studio/Components/SelectFile.razor.cs index 124f104f..d9845f85 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor.cs +++ b/app/MindWork AI Studio/Components/SelectFile.razor.cs @@ -38,13 +38,9 @@ public partial class SelectFile : MSGComponentBase public bool EnableDragDrop { 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. + /// Makes this component the default target of its area, meaning of its page, assistant, or + /// dialog: it then also takes the drops which land anywhere in that area without hitting a zone + /// of their own. /// [Parameter] public bool CatchAllDocuments { get; set; } diff --git a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor index 8080114e..6de48770 100644 --- a/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ChatTemplateDialog.razor @@ -3,6 +3,8 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @T("Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements.") @@ -90,7 +92,6 @@ + @if (this.IsReadOnly) diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor index 9679ead3..4cda2ad2 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalDirectoryDialog.razor @@ -5,6 +5,8 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @* ReSharper disable once CSharpWarnings::CS8974 *@ + } else { @@ -191,6 +193,7 @@ + diff --git a/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor index 965a51f6..96d58c07 100644 --- a/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DataSourceLocalFileDialog.razor @@ -5,6 +5,8 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @* ReSharper disable once CSharpWarnings::CS8974 *@ + } else { @@ -191,6 +193,7 @@ + diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor index 403dd67e..f6facdb3 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor @@ -2,13 +2,15 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @T("See how we load your file. Review the content before we process it further.") @if (this.Document is null) { - + } else { @@ -100,6 +102,7 @@ } } + diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor index 9838bf7b..941300f6 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBatchProcessing.razor @@ -12,6 +12,8 @@ + @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ + @@ -24,12 +26,12 @@ @if (this.SettingsManager.ConfigurationData.BatchProcessing.PromptSource is BatchProcessingPromptSource.FREE_PROMPT) { - + } else if (this.SettingsManager.ConfigurationData.BatchProcessing.PromptSource is BatchProcessingPromptSource.FILE_IMPORT) { - + } else @@ -86,6 +88,7 @@ + @T("Close") diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index a7b85d53..e6c41206 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -2,7 +2,9 @@ @using AIStudio.Settings.DataModel @inherits MSGComponentBase -
+@* The chat is a drop area: a file dropped anywhere in it hangs itself on the composer, which is + what users are used to. *@ + @@ -166,4 +168,4 @@ } -
+ diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 7bbe889e..818647e9 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -4,7 +4,9 @@ @inherits MSGComponentBase @attribute [Route(Routes.PLUGINS)] -
+@* The page is its own drop target: a plugin archive may be dropped anywhere on it, and there is no + inner zone to hand that role to. Hence the page owns the scope state instead of reading it. *@ + @T("Plugins") @@ -153,4 +155,4 @@ -
+ diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 3be2e933..5eac67b6 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -43,10 +43,10 @@ public partial class Plugins : MSGComponentBase private bool isSharingPlugin; /// - /// Number of active drop areas above this page. While there is any, another component owns the - /// dropped files and this page must not catch them. + /// The drop area of this page. The page owns it rather than reading it from a cascading value, + /// because a component cannot read what it cascades itself. /// - private uint numDropAreasAboveThis; + private readonly DropZoneScopeState dropZoneScope = new($"plugins-page-{Guid.NewGuid():N}"); private bool isDraggingOverPage; @@ -61,10 +61,10 @@ public partial class Plugins : MSGComponentBase protected override async Task OnInitializedAsync() { - this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED, Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]); + this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED, Event.HIGHLIGHT_DROP_ZONE, Event.PATHS_DROPPED ]); - // Register the whole page as a drop area, so users can drop a plugin archive anywhere on it: - await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, DropLayers.PAGES); + // The whole page is the drop target, so users can drop a plugin archive anywhere on it: + this.dropZoneScope.TryBecomeDefaultZone(this); this.groupConfig = new TableGroupDefinition { @@ -92,8 +92,7 @@ public partial class Plugins : MSGComponentBase protected override void DisposeResources() { - // Release the drop area again, so lower layers can catch dropped files: - this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES).Observe($"{nameof(Plugins)}: releasing the drop area"); + this.dropZoneScope.ReleaseDefaultZone(this); base.DisposeResources(); } @@ -572,39 +571,24 @@ public partial class Plugins : MSGComponentBase await this.InvokeAsync(this.StateHasChanged); break; - case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this: - if (data is int registeredLayer && registeredLayer > DropLayers.PAGES) - this.numDropAreasAboveThis++; - + case Event.HIGHLIGHT_DROP_ZONE when data is DropZoneHighlight highlight: + this.ApplyHighlight(this.IsThisZone(highlight.ZoneId)); break; - case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this: - if (data is int unregisteredLayer && unregisteredLayer > DropLayers.PAGES && this.numDropAreasAboveThis > 0) - this.numDropAreasAboveThis--; + case Event.PATHS_DROPPED when data is DroppedPaths dropped: + // Whoever the drop was meant for, the drag is over and nothing stays highlighted: + this.ApplyHighlight(false); - break; - - case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }: - if (!this.CanCatchDroppedFile()) + if (!this.IsThisZone(dropped.ZoneId)) return; - this.isDraggingOverPage = true; - await this.InvokeAsync(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.isDraggingOverPage = false; - await this.InvokeAsync(this.StateHasChanged); - break; - - case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var droppedPaths }: - this.isDraggingOverPage = false; - await this.InvokeAsync(this.StateHasChanged); if (!this.CanCatchDroppedFile()) + { + LOG.LogDebug("The plugins page cannot import right now and swallowed {Count} dropped path(s).", dropped.Paths.Count); return; + } - await this.ImportDroppedPluginArchiveAsync(droppedPaths); + await this.ImportDroppedPluginArchiveAsync(dropped.Paths); break; } } @@ -612,10 +596,33 @@ public partial class Plugins : MSGComponentBase #endregion /// - /// Decides whether this page may process dropped files: only when no drop area above it is - /// active and when the organization allows importing plugins at all. + /// Decides whether the named zone is this page. /// - private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && this.AllowPluginImport && !this.isImportingAssistantPlugin; + /// The ID the hit test reported, or null when it hit nothing. + private bool IsThisZone(string? zoneId) => zoneId is not null && zoneId == this.dropZoneScope.ScopeId; + + /// + /// Marks the page as the drop target, or takes that mark away. + /// + /// + /// The comparison is not for tidiness: a throttled drag-over event arrives about ten times per + /// second, and without it every one of them would render the whole plugin table anew. + /// + private void ApplyHighlight(bool shouldBeHighlighted) + { + var highlighted = shouldBeHighlighted && this.CanCatchDroppedFile(); + if (highlighted == this.isDraggingOverPage) + return; + + this.isDraggingOverPage = highlighted; + this.StateHasChanged(); + } + + /// + /// Decides whether this page may process dropped files: only when the organization allows + /// importing plugins at all and no import is running. + /// + private bool CanCatchDroppedFile() => this.AllowPluginImport && !this.isImportingAssistantPlugin; /// /// Imports a plugin archive the user dropped onto the page. Anything that is not exactly one diff --git a/app/MindWork AI Studio/Tools/DropLayers.cs b/app/MindWork AI Studio/Tools/DropLayers.cs deleted file mode 100644 index 8f1a370b..00000000 --- a/app/MindWork AI Studio/Tools/DropLayers.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace AIStudio.Tools; - -public static class DropLayers -{ - public const int ROOT = 0; - - public const int PAGES = 10; - public const int ASSISTANTS = 20; - - public const int DIALOGS = 100; -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/DropZoneScopeState.cs b/app/MindWork AI Studio/Tools/DropZoneScopeState.cs new file mode 100644 index 00000000..e0d61d89 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DropZoneScopeState.cs @@ -0,0 +1,57 @@ +namespace AIStudio.Tools; + +/// +/// The shared state of one drop zone scope, meaning one page, one assistant, or one dialog. +/// +/// +/// A scope is the area whose drops end up at its default target whenever the cursor is not over a +/// more specific zone. That is what users are used to: a file dropped anywhere in the chat hangs +/// itself on the composer. This object connects the two sides -- the scope cascades it inwards, and +/// the zone which wants to be the default target claims it here. +/// +/// The ID of the element the scope renders. +public sealed class DropZoneScopeState(string scopeId) +{ + /// + /// The ID of the element the scope renders. The hit test reports it for every point inside the + /// area which no more specific zone covers. + /// + public string ScopeId { get; } = scopeId; + + private object? defaultZone; + + /// + /// Makes the given zone the default target of this scope, unless another zone was there first. + /// + /// + /// Zones initialize in render order, so the first one in the markup wins. Two zones asking for + /// the same area is a mistake in the markup rather than a state worth resolving, and taking the + /// first one is at least a rule which can be stated and logged. Asking twice is no mistake, + /// though: a zone whose parameters are set anew has to keep the role it already holds. + /// + /// The zone that wants to be the default target. + /// True if the zone is the default target of this scope from now on. + public bool TryBecomeDefaultZone(object zone) + { + if (this.defaultZone is not null && !ReferenceEquals(this.defaultZone, zone)) + return false; + + this.defaultZone = zone; + return true; + } + + /// + /// Gives the role of the default target up again so that another zone can take it. + /// + /// + /// Every zone that took the role has to do this when it is disposed. Without it, an area would + /// lose its default target for good as soon as the zone holding it is created anew -- which is + /// what happens on every navigation and every time a dialog is opened again. + /// + /// The zone that gives the role up. + public void ReleaseDefaultZone(object zone) + { + if (ReferenceEquals(this.defaultZone, zone)) + this.defaultZone = null; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index e68ad498..fba32b94 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -213,16 +213,6 @@ public enum Event // File attachment events: // - /// - /// Registers a file drop area for file attachment handling. - /// - REGISTER_FILE_DROP_AREA, - - /// - /// Unregisters a file drop area from file attachment handling. - /// - UNREGISTER_FILE_DROP_AREA, - /// /// Names the drop zone under the cursor of a running drag so that exactly this one is highlighted. ///