Selection
What the user has selected, and the Range operations that change it: wrap a selection in a mark, replace it, measure where it is on screen, save and restore a caret across a re-render, and find the text position under a pointer.
@inject Bit.Butil.Selection selectionMDN reference
Range holds live DOM node references, so it cannot cross the interop boundary.
This works the way the browser's own editing commands do instead: every call acts on the current
selection, or on a range expressed as character offsets inside one element you name. Those
offsets count text nodes only, so they survive markup changing around the text - which is what
makes them safe to save across a re-render.
Get returns the selection's text, whether it is collapsed to a caret, and its offsets. GetHtml returns the selected markup instead of its text - what a 'quote this passage' feature needs. The markup is page content, so treat it as untrusted before storing it.
@inject Bit.Butil.Selection selection
var current = await selection.Get(); // WindowSelection?
var text = await selection.GetText();
var html = await selection.GetHtml();Select any part of this paragraph - including some of its markup - and then press one of the buttons below. The selection is a document-wide thing, so every section below carries a paragraph of its own to work on.
GetRects gives one rectangle per line box, because a selection spanning wrapped text is not a rectangle - that is what a highlight overlay draws. GetBoundingRect gives the single enclosing rectangle, which is where a floating toolbar goes.
var rects = await selection.GetRects(); // one per line box
var box = await selection.GetBoundingRect(); // where to anchor a toolbar
var inside = await selection.ContainsElement(element, partly: true);Select part of this paragraph - across a line break, to see one rectangle per line box - and then measure it.
Selecting an element's contents is the 'select all in this box' primitive. SelectRange takes character offsets within one element, which is how a caret is restored after re-rendering.
await selection.SelectElement(element); // the element itself, tags and all
await selection.SelectElementContents(element); // only what is inside it
await selection.SelectRange(element, 0, 12);
await selection.Collapse(toStart: true);
await selection.RemoveAll();The buttons below select this paragraph, its contents, or its first 24 characters.
Read the offsets before a re-render, hand them back afterwards. Try it on the editable box: select something, save, click away, then restore.
var saved = await selection.GetRangeIn(editor);
// ... re-render ...
if (saved is not null) await selection.SelectRange(editor, saved.Start, saved.End);Surround wraps the selection in a new element - the highlighting primitive. It returns false when the selection's ends are in different elements, which is a normal thing for a user to have selected rather than an error: the browser refuses because no single element could contain it.
var wrapped = await selection.Surround("mark");
await selection.ReplaceWithText("[redacted]");
await selection.DeleteContents();Select part of this paragraph and then wrap, replace or delete it. The edits land here, where you can see them.
Resolves a viewport point to a text position - which character of which node the pointer is over. Click anywhere in the sample paragraph above and this reports what was under the click. The basis of drag-to-insert, hover dictionaries and click-to-annotate.
private async Task OnClick(MouseEventArgs e)
{
var caret = await selection.CaretFromPoint(e.ClientX, e.ClientY);
// caret.Offset within caret.Text, inside a <caret.ElementTag>
}Click anywhere in this paragraph and the text position under the pointer is reported below - which character, of which node.
selectionchange fires on every caret move, so it is frequent: read the selection inside the handler rather than doing work per event, and debounce anything expensive. The subscription below is live while this page is open - select something and watch it count.
IAsyncDisposable
Bit.Butil.Selection selection
{
private ButilSubscription? _subscription;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
_subscription = await selection.OnChange(async () =>
{
var text = await selection.GetText();
// ... update a floating toolbar ...
});
}
public async ValueTask DisposeAsync()
{
if (_subscription is not null) await _subscription.DisposeAsync(); // stops listening
}
}Start watching, then select part of this paragraph and drag the caret through it - the count below climbs with every move.
API reference
ValueTask<bool> IsSupported()ValueTask<WindowSelection?> Get()ValueTask<string> GetText()ValueTask<string> GetHtml()ValueTask<Rect[]> GetRects()ValueTask<Rect?> GetBoundingRect()ValueTask<bool> ContainsElement(ElementReference element, bool partly = true)ValueTask<bool> SelectElement(ElementReference element)ValueTask<bool> SelectElementContents(ElementReference element)ValueTask<bool> SelectRange(ElementReference element, int start, int end)ValueTask<SelectionOffsets?> GetRangeIn(ElementReference element)ValueTask RemoveAll()ValueTask<bool> Collapse(bool toStart = false)ValueTask<bool> Surround(string tagName, string? className = null, string? style = null)ValueTask<bool> ReplaceWithText(string text)ValueTask<bool> DeleteContents()ValueTask<bool> IsCaretFromPointSupported()ValueTask<CaretPosition?> CaretFromPoint(double x, double y)ValueTask<ButilSubscription> OnChange(Action handler)ValueTask DisposeAsync()