diff --git a/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs b/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs index 4011046a..63e8f62a 100644 --- a/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs +++ b/app/MindWork AI Studio/Tools/JsRuntimeExtensions.cs @@ -71,6 +71,40 @@ public static class JsRuntimeExtensions return await jsRuntime.TryInvokeVoidAsync(identifier, args); } + /// + /// Calls a JavaScript function which returns a value, unless the circuit is known to be disconnected. + /// + /// + /// The two parts of the result answer two different questions, and callers must keep them apart. + /// Whether the browser ran the function at all comes first: a call which never arrived says nothing + /// about the page, so nobody may act on an answer they did not get. What the function returned is the + /// second question, and there a null is a legitimate answer -- it means the browser looked and found + /// nothing. + /// + /// The JS runtime to call. + /// The circuit of the caller. + /// The name of the JavaScript function. + /// The arguments for the JavaScript function. + /// Whether the browser ran the function, and what it returned. + public static async ValueTask<(bool WasInvoked, TValue? Value)> TryInvokeAsync(this IJSRuntime jsRuntime, CircuitStateService circuitState, string identifier, params object?[]? args) + { + if (!circuitState.IsConnected) + { + LOGGER.LogDebug("The JS call '{Identifier}' was skipped because the browser connection of the circuit '{CircuitId}' is down.", identifier, circuitState.CircuitId); + return (false, default); + } + + try + { + return (true, await jsRuntime.InvokeAsync(identifier, args)); + } + catch (Exception exception) + { + LogInvocationFailure(exception, identifier); + return (false, default); + } + } + /// /// Calls a function of a JavaScript module which returns nothing, and tolerates a circuit which is /// already gone. See the remarks on the JS runtime variant of this method. diff --git a/app/MindWork AI Studio/Tools/Rust/DropPosition.cs b/app/MindWork AI Studio/Tools/Rust/DropPosition.cs new file mode 100644 index 00000000..2f4d5e5b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/DropPosition.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Tools.Rust; + +/// +/// The cursor position of a drag and drop event. +/// +/// +/// The coordinates are viewport-relative CSS pixels, on every platform. The Rust runtime has already +/// dealt with the platform differences -- device pixels on Windows, logical points on macOS and Linux -- +/// so these numbers can be handed to the browser for a hit test without any further conversion. +/// +/// The distance from the left edge of the viewport, in CSS pixels. +/// The distance from the top edge of the viewport, in CSS pixels. +public readonly record struct DropPosition(double X, double Y); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs index 54628930..3cc001ae 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs @@ -5,7 +5,8 @@ namespace AIStudio.Tools.Rust; /// /// The type of the Tauri event. /// The payload of the Tauri event. -public readonly record struct TauriEvent(TauriEventType EventType, List Payload) +/// Where the cursor was, for the drag and drop events which know it. +public readonly record struct TauriEvent(TauriEventType EventType, List Payload, DropPosition? Position = null) { /// /// Attempts to parse the first payload element as a shortcut. @@ -29,6 +30,28 @@ public readonly record struct TauriEvent(TauriEventType EventType, List return TryParseSnakeCase(this.Payload[0], out shortcut); } + /// + /// Reads the cursor position of a drag and drop event. + /// + /// + /// The coordinates are viewport-relative CSS pixels, ready for a hit test in the browser. Only the + /// drag and drop events carry them, which is why the caller has to ask instead of assuming. + /// + /// The distance from the left edge of the viewport, in CSS pixels. + /// The distance from the top edge of the viewport, in CSS pixels. + /// True if the event carried a position, false otherwise. + public bool TryGetDropPosition(out double x, out double y) + { + x = 0.0; + y = 0.0; + if (this.Position is not { } position) + return false; + + x = position.X; + y = position.Y; + return true; + } + /// /// Reads a portal shortcut change and its effective display name. /// diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs index 6ad50eff..dc7db880 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs @@ -13,6 +13,7 @@ public enum TauriEventType WINDOW_NOT_FOCUSED, FILE_DROP_HOVERED, + FILE_DROP_OVER, FILE_DROP_DROPPED, FILE_DROP_CANCELED, diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs index 62538938..67c6bb09 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs @@ -46,7 +46,14 @@ public partial class RustService and not TauriEventType.UNKNOWN and not TauriEventType.PING) { - this.logger!.LogDebug("Received Tauri event {EventType} with {NumPayloadItems} payload items.", tauriEvent.EventType, tauriEvent.Payload.Count); + // + // Log every event but the drag-over ones: those arrive about ten times per + // second for as long as a drag lasts, and one line each would bury everything + // else in the log. + // + if(tauriEvent.EventType is not TauriEventType.FILE_DROP_OVER) + this.logger!.LogDebug("Received Tauri event {EventType} with {NumPayloadItems} payload items.", tauriEvent.EventType, tauriEvent.Payload.Count); + await MessageBus.INSTANCE.SendMessage(null, Event.TAURI_EVENT_RECEIVED, tauriEvent); } } diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index ea5b53a5..7426fcac 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -286,4 +286,52 @@ window.localShortcut = { document.removeEventListener('keydown', handler, true) localShortcutHandlers.delete(id) } +} + +// What floats above the page without ever being a drop target. Two of these take part in hit testing as +// MudBlazor 8.15 stands: an open .mud-popover -- a closed one already declines pointer events through +// .mud-popover:not(.mud-popover-open) -- and .mud-snackbar, which asks for them explicitly with +// pointer-events: auto even though its container declines them, and snackbars appear constantly in this +// app. Without this list, a drag would be answered by whatever happens to float on screen rather than by +// the page below it. The remaining three are named because they surround those two: .mud-tooltip is the +// content of a popover, while #mud-snackbar-container and .mud-badge-wrapper carry pointer-events: none +// today and therefore never reach a hit test at all. Should a MudBlazor version drop that, they are +// covered here already. Children of all of them have to be skipped as well, which is why the test below +// uses closest rather than matches. +const skippedDropOverlays = '.mud-popover, .mud-tooltip, .mud-snackbar, #mud-snackbar-container, .mud-badge-wrapper' + +// The drop zones of the app, addressed by the cursor position of a native drag and drop event. +// +// The arbitration between overlapping zones is left to the browser, and it can be: MudBlazor 8.15 gives +// neither .mud-dialog-container nor .mud-overlay a pointer-events: none. Both fill the viewport while a +// dialog is open, so a point beside the dialog box hits the container, and nothing there is a drop zone. A +// drop, therefore, cannot reach through an open dialog into the page behind it -- the very thing the app +// used to enforce by counting layers in C#. That single CSS property carries this whole design, so it +// belongs on the checklist for every MudBlazor major version, starting with the move to 9. +window.dropZones = { + + // Names the drop zone at the given viewport position, or null when there is none. + // + // The stack of elements is walked from the top down rather than asking for the topmost one alone, + // because the topmost one may be an overlay from the list above and skipping it has to reveal what + // lies beneath. The first element which is not skipped ends the walk, whether it belongs to a drop + // zone or not: anything unknown blocks on purpose, so a drop can never slip through something the + // user sees as being in the way. Within that element, closest resolves from the inside out, so a + // specific zone inside a page-wide one wins -- which is exactly the precedence we want. + hitTest: function (x, y) { + for (const element of document.elementsFromPoint(x, y)) { + if (element.closest(skippedDropOverlays)) + continue + + return element.closest('[data-drop-zone-id]')?.getAttribute('data-drop-zone-id') ?? null + } + + return null + }, + + // Every drop zone currently in the DOM, in document order. This is for diagnostics only: when a drop + // lands nowhere, it answers the question of which zones would have been available at that moment. + list: function () { + return Array.from(document.querySelectorAll('[data-drop-zone-id]'), zone => zone.getAttribute('data-drop-zone-id')) + } } \ No newline at end of file