loading
Note:
Why the name is TextEditContext Blazor's own Microsoft.AspNetCore.Components.Forms.EditContext is in every component's implicit usings, so a Butil type called EditContext would make the name ambiguous in every razor file that touches forms. The browser type it wraps is still EditContext.
Warning:
This is not a drop-in contenteditable Attaching a context takes the element out of the DOM's own editing, and everything the browser was doing becomes yours: drawing the caret, painting the selection, drawing the IME's underlines, and telling the platform where the surface is so the candidate window lands in the right place. Reach for it when contenteditable has already failed you - a code editor, a canvas-drawn document - not before. Chromium only.

Support check

IsSupported

True when the runtime exposes EditContext. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.

C#
@inject Bit.Butil.TextEditContext editContext

var supported = await editContext.IsSupported();
Live sample
support check output
Results will appear here when you interact with the samples.

A surface that renders its own text

Attach

Attach to a focusable element (give it a tabindex - it does not need to be contenteditable), keep your own model, and render it however you like. Every keystroke, IME composition, dictation and autocorrect arrives as a TextEditContextUpdate: replace [UpdateRangeStart, UpdateRangeEnd) of your buffer with Text. Value is that result computed for you, for an editor that simply re-renders everything.

C#
_handle = await editContext.Attach(
    _surface,
    onTextUpdate: update =>
    {
        _text = update.Value;
        _caret = update.SelectionStart;
        InvokeAsync(StateHasChanged);
    },
    options: new TextEditContextOptions { Text = _text },
    onComposition: composing => { _composing = composing; InvokeAsync(StateHasChanged); },
    onFormatUpdate: formats => { _formats = formats; InvokeAsync(StateHasChanged); });
Live sample
Attach, click here and type. The text below is rendered from C#, not by the browser.
State caret 0 · composing: False · IME formats: 0
edit context output
Results will appear here when you interact with the samples.

Tell the input method what you changed

UpdateText / UpdateSelection

A programmatic edit - a paste, an undo, a completion accepted - has to be reported back, or the IME keeps composing against a buffer that no longer matches what the user sees. This is the classic edit-context bug.

Razor
@code {
    // The editor's own model, and the handle from Attach above. The two have to be kept in step:
    // the input method builds its candidates from what it was last told, not from the DOM.
    private string _text = "";
    private int _caret;
    private TextEditContextHandle? _handle;

    private async Task Insert(string inserted)
    {
        // insert at the caret from your own code
        _text = _text.Insert(_caret, inserted);
        await _handle!.UpdateText(_caret, _caret, inserted);
        await _handle.UpdateSelection(_caret + inserted.Length, _caret + inserted.Length);

        _caret += inserted.Length;
    }
}
Live sample
Attach, click here and type - then insert from C# and watch the buffer stay in step.
update output
Results will appear here when you interact with the samples.

Place the IME candidate window

UpdateControlBounds / UpdateSelectionBounds

The platform needs to know where the editing surface is on screen to place the IME's candidate list next to the text being typed. Without it the list can land nowhere near the caret. Report the bounds after every layout change - a resize, a scroll.

Razor
<div @ref="_surface" contenteditable="true">...</div>

@code {
    private ElementReference _surface;
    private TextEditContextHandle? _handle;   // from Attach

    // Where the IME draws its candidate window. Without this it guesses, and on a custom editor it
    // guesses wrong - usually the top-left corner of the page.
    private async Task PlaceCandidates()
    {
        var rect = await _surface.GetBoundingClientRect();
        await _handle!.UpdateControlBounds(rect.X, rect.Y, rect.Width, rect.Height);
    }
}
Live sample
The surface whose bounds are reported. Attach, then report - the numbers below are this box.
bounds output
Results will appear here when you interact with the samples.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes EditContext.
Attach
ValueTask<TextEditContextHandle?> Attach(ElementReference element, Action<TextEditContextUpdate> onTextUpdate, TextEditContextOptions? options = null, Action<bool>? onComposition = null, Action<TextEditFormat[]>? onFormatUpdate = null)
Attaches an edit context to an element, taking over its text input. Null when the runtime has no EditContext.
Handle.GetText
ValueTask<string?> GetText()
The context's current text, or null once detached.
Handle.GetSelection
ValueTask<TextEditSelection?> GetSelection()
Where the caret or selection is, or null once detached.
Handle.UpdateText
ValueTask UpdateText(int rangeStart, int rangeEnd, string text)
Tells the input method about a text change your own code made.
Handle.UpdateSelection
ValueTask UpdateSelection(int start, int end)
Tells the input method where the caret or selection moved to.
Handle.UpdateControlBounds
ValueTask UpdateControlBounds(double x, double y, double width, double height)
Where the whole editing surface is on screen, so the IME can place its candidate window.
Handle.UpdateSelectionBounds
ValueTask UpdateSelectionBounds(double x, double y, double width, double height)
Where the selection is on screen - the finer-grained companion to UpdateControlBounds.
Handle.DisposeAsync
ValueTask DisposeAsync()
Detaches the context, handing the element back to the DOM's own editing. Always dispose.
DisposeAsync
ValueTask DisposeAsync()
Detaches every context attached through this instance and releases its interop reference.
An unhandled error has occurred. Reload 🗙