Window
The top-level browsing context: DOM events, native dialogs, dimensions, scrolling, popups, base64 helpers, text selection and media queries - all callable from C#.
@inject Bit.Butil.Window windowMDN reference
Attach a handler to any DOM event raised on window. SubscribeEvent returns a ButilSubscription - an IAsyncDisposable handle that detaches the listener when disposed, which is the safest pattern for lambdas.
@implements IAsyncDisposable
@inject Bit.Butil.Window window
@code {
private ButilSubscription? subscription;
private async Task Subscribe()
{
subscription = await window.SubscribeEvent<ButilKeyboardEventArgs>(
ButilEvents.KeyDown,
args => Console.WriteLine($"{args.Key} ({args.Code})"));
}
public async ValueTask DisposeAsync()
{
if (subscription is not null) await subscription.DisposeAsync();
}
}While subscribed, press any key and watch the console below.
mousemove, pointermove, scroll, resize, wheel and touchmove all fire about once a frame, and every one of them is an interop round trip - a JSON serialization plus, under Blazor Server, a SignalR message and a network hop. MinInterval caps how often the handler is called. The gate runs in JavaScript, before the round trip, leading-edge with a trailing send: the first move after an idle gap goes through at once, and the last move of a gesture always arrives, so the handler ends up holding where the pointer stopped rather than where it was one sample earlier. preventDefault and stopPropagation are unaffected - they still run on every event. Subscribe both below and move the mouse: the counters diverge immediately.
subscription = await window.SubscribeEvent<ButilMouseEventArgs>(
ButilEvents.MouseMove,
args => Console.WriteLine($"{args.ClientX}, {args.ClientY}"),
new ButilEventListenerOptions
{
Passive = true,
MinInterval = TimeSpan.FromMilliseconds(100),
});Ungated: 0 calls · gated: 0 calls. Move the pointer across the page and watch the two counts pull apart.
Before-unload and page lifecycle
AddBeforeUnload / RemoveBeforeUnload / SubscribeFreeze / SubscribeResumeAddBeforeUnload makes the browser show a confirmation prompt before the user leaves or reloads the page - ideal for unsaved-changes protection. SubscribeFreeze and SubscribeResume observe the back/forward-cache lifecycle so you can release and re-acquire expensive resources.
await window.AddBeforeUnload("You have unsaved changes.");
// later, when the data is saved:
await window.RemoveBeforeUnload();
var freezeSub = await window.SubscribeFreeze(() => { /* release resources */ });
var resumeSub = await window.SubscribeResume(() => { /* re-acquire them */ });Enable it, then try reloading this tab - the browser asks for confirmation.
The classic blocking browser dialogs. Confirm resolves to a bool and Prompt resolves to the entered text, or null when the user cancels.
await window.Alert("Hello from C#!");
bool ok = await window.Confirm("Proceed with the operation?");
string? name = await window.Prompt("What is your name?", "Butil");Dimensions and offsets
GetInnerWidth / GetInnerHeight / GetOuterWidth / GetOuterHeight / GetScreenX / GetScreenY / GetScrollX / GetScrollYRead the viewport and window sizes, the window position on the screen, and how far the document has been scrolled.
var innerWidth = await window.GetInnerWidth();
var innerHeight = await window.GetInnerHeight();
var outerWidth = await window.GetOuterWidth();
var outerHeight = await window.GetOuterHeight();
var screenX = await window.GetScreenX();
var screenY = await window.GetScreenY();
var scrollX = await window.GetScrollX();
var scrollY = await window.GetScrollY();Scroll to an absolute position or by a relative offset. The ScrollOptions overloads add smooth or instant behavior control.
await window.Scroll(0, 0);
await window.ScrollBy(0, 400);
await window.Scroll(new ScrollOptions
{
Top = 0,
Behavior = ScrollBehavior.Smooth,
});Open returns an opaque tracking id you can later pass to Close to close that popup, or null when the browser blocked it. Print opens the print dialog for the current document, and Stop aborts any in-flight page loading.
var popupId = await window.Open("https://bitplatform.dev", "_blank", new WindowFeatures
{
Popup = true,
Width = 600,
Height = 400,
NoOpener = true,
});
if (popupId is not null)
{
await window.Close(popupId);
}
await window.Print();Btoa produces a base64-encoded ASCII string from binary string data and Atob decodes it back - the same semantics as the browser globals of the same names.
var encoded = await window.Btoa("Hello Butil!"); // "SGVsbG8gQnV0aWwh"
var decoded = await window.Atob(encoded); // "Hello Butil!"Find and text selection
Find / GetSelection / GetSelectionText / SelectElement / CopySelection / ClearSelectionFind searches (and selects) text on the page. The selection helpers read the current selection as a snapshot, programmatically select an element's content, copy the selection to the clipboard, or clear it.
bool found = await window.Find("butil", caseSensitive: false, wrapAround: true);
WindowSelection? selection = await window.GetSelection();
string text = await window.GetSelectionText();
await window.SelectElement(myElementReference);
bool copied = await window.CopySelection();
await window.ClearSelection();Highlight any text on this page, then use the selection buttons.
An ordinary selection can only describe boundaries in the document tree: one that starts or ends inside a shadow root is reported against the host element instead, which loses exactly what a component library needs to know. GetComposedRanges reports the real boundary points - but only inside the shadow roots you name, so encapsulation is kept. The boundary nodes themselves can't cross interop, so what identifies them is reported instead.
var ranges = await window.GetComposedRanges(_componentHost);
foreach (var range in ranges)
{
if (range.CrossesShadowBoundary) HandleInternalSelection(range);
}Select some text on this page first. With no shadow hosts passed, this reports the same boundaries an ordinary selection would - which is the point: a root you don't name stays hidden.
Context, naming and media queries
IsSecureContext / GetOrigin / GetName / SetName / GetLocationBar / MatchMedia / WatchMatchMediaInspect the browsing context: secure-context state, origin, the window name and the location bar visibility. MatchMedia evaluates a CSS media query once, while WatchMatchMedia keeps a handler subscribed to its change event via a disposable subscription.
IAsyncDisposable
Bit.Butil.Window window
{
private ButilSubscription? sub;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
bool secure = await window.IsSecureContext();
string origin = await window.GetOrigin();
BarProp locationBar = await window.GetLocationBar();
await window.SetName("butil-demo");
string name = await window.GetName();
MediaQueryList result = await window.MatchMedia("(max-width: 600px)");
sub = await window.WatchMatchMedia(
"(prefers-color-scheme: dark)",
mql => Console.WriteLine($"dark mode: {mql.Matches}"));
}
public async ValueTask DisposeAsync()
{
if (sub is not null) await sub.DisposeAsync();
}
}Watch the query, then resize the browser window to see change notifications.
Open in response to a user gesture, and popup blockers can still
veto it - always check the returned id for null before assuming the window exists.
RemoveEventListener matches by delegate identity, so a freshly created lambda never matches
the registered one. Keep the original delegate in a field, or prefer SubscribeEvent and dispose
the returned ButilSubscription.
API reference
Task AddEventListener<T>(string domEvent, Action<T> listener, bool useCapture = false)Task RemoveEventListener<T>(string domEvent, Action<T> listener, bool useCapture = false)Task<ButilSubscription> SubscribeEvent<T>(string domEvent, Action<T> listener, bool useCapture = false)TimeSpan? MinInterval { get; set; }Task AddBeforeUnload()Task RemoveBeforeUnload()Task<ButilSubscription> SubscribeFreeze(Action handler)Task<ButilSubscription> SubscribeResume(Action handler)Task<float> GetInnerWidth() / Task<float> GetInnerHeight()Task<float> GetOuterWidth() / Task<float> GetOuterHeight()Task<float> GetScreenX() / Task<float> GetScreenY()Task<float> GetScrollX() / Task<float> GetScrollY()Task<bool> IsSecureContext()Task<string> GetOrigin()Task<BarProp> GetLocationBar()Task<string> GetName() / Task SetName(string value)Task Alert(string? message = null)Task<bool> Confirm(string? message = null)Task<string?> Prompt(string? message, string? defaultValue)Task<string> Btoa(string data)Task<string> Atob(string data)Task<bool> Find(string? text = null, bool? caseSensitive = null, bool? backward = null, bool? wrapAround = null, bool? wholeWord = null, bool? searchInFrame = null)Task<WindowSelection?> GetSelection()Task<string> GetSelectionText()Task SelectElement(Microsoft.AspNetCore.Components.ElementReference element)Task<bool> CopySelection()Task ClearSelection()Task<bool> IsComposedRangesSupported()Task<ComposedRange[]> GetComposedRanges(params ElementReference[] shadowHosts)Task<MediaQueryList> MatchMedia(string query)Task<Guid> SubscribeMatchMedia(string query, Action<MediaQueryList> handler)Task<ButilSubscription> WatchMatchMedia(string query, Action<MediaQueryList> handler)ValueTask UnsubscribeMatchMedia(Guid id)Task<string?> Open(string? url = null, string? target = null, WindowFeatures? windowFeatures = null)Task Close(string? id = null)Task Focus() / Task Blur()Task Print()Task Stop()Task Scroll(float? x, float? y)Task ScrollBy(float? x, float? y)ValueTask DisposeAsync()Task<double> GetDevicePixelRatio()Task<bool> IsCrossOriginIsolated()Task<bool> IsInIframe()Task<int> GetFrameCount()Task MoveTo(float x, float y) | Task MoveBy(float x, float y)Task ResizeTo(float width, float height) | Task ResizeBy(float width, float height)