Bring the drop position into .NET and add the DOM hit test

This commit is contained in:
Thorsten Sommer 2026-09-10 10:49:08 +02:00
parent 513f86d45c
commit 9e3c6a569d
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
6 changed files with 128 additions and 2 deletions

View File

@ -71,6 +71,40 @@ public static class JsRuntimeExtensions
return await jsRuntime.TryInvokeVoidAsync(identifier, args);
}
/// <summary>
/// Calls a JavaScript function which returns a value, unless the circuit is known to be disconnected.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="jsRuntime">The JS runtime to call.</param>
/// <param name="circuitState">The circuit of the caller.</param>
/// <param name="identifier">The name of the JavaScript function.</param>
/// <param name="args">The arguments for the JavaScript function.</param>
/// <returns>Whether the browser ran the function, and what it returned.</returns>
public static async ValueTask<(bool WasInvoked, TValue? Value)> TryInvokeAsync<TValue>(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<TValue>(identifier, args));
}
catch (Exception exception)
{
LogInvocationFailure(exception, identifier);
return (false, default);
}
}
/// <summary>
/// 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.

View File

@ -0,0 +1,13 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// The cursor position of a drag and drop event.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="X">The distance from the left edge of the viewport, in CSS pixels.</param>
/// <param name="Y">The distance from the top edge of the viewport, in CSS pixels.</param>
public readonly record struct DropPosition(double X, double Y);

View File

@ -5,7 +5,8 @@ namespace AIStudio.Tools.Rust;
/// </summary>
/// <param name="EventType">The type of the Tauri event.</param>
/// <param name="Payload">The payload of the Tauri event.</param>
public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload)
/// <param name="Position">Where the cursor was, for the drag and drop events which know it.</param>
public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload, DropPosition? Position = null)
{
/// <summary>
/// Attempts to parse the first payload element as a shortcut.
@ -29,6 +30,28 @@ public readonly record struct TauriEvent(TauriEventType EventType, List<string>
return TryParseSnakeCase(this.Payload[0], out shortcut);
}
/// <summary>
/// Reads the cursor position of a drag and drop event.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="x">The distance from the left edge of the viewport, in CSS pixels.</param>
/// <param name="y">The distance from the top edge of the viewport, in CSS pixels.</param>
/// <returns>True if the event carried a position, false otherwise.</returns>
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;
}
/// <summary>
/// Reads a portal shortcut change and its effective display name.
/// </summary>

View File

@ -13,6 +13,7 @@ public enum TauriEventType
WINDOW_NOT_FOCUSED,
FILE_DROP_HOVERED,
FILE_DROP_OVER,
FILE_DROP_DROPPED,
FILE_DROP_CANCELED,

View File

@ -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);
}
}

View File

@ -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'))
}
}