TextEditContext
The EditContext API: text input, IME composition and the thing on screen become three separate concerns instead of one contenteditable element that is all of them at once.
@inject Bit.Butil.TextEditContext editContextMDN reference
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.
contenteditable has already failed you - a code editor, a
canvas-drawn document - not before. Chromium only.
True when the runtime exposes EditContext. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.
@inject Bit.Butil.TextEditContext editContext
var supported = await editContext.IsSupported();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.
_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); });caret 0 · composing: False · IME formats: 0A 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.
{
// 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;
}
}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.
<div @ref="_surface" contenteditable="true">...</div>
{
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);
}
}API reference
ValueTask<bool> IsSupported()ValueTask<TextEditContextHandle?> Attach(ElementReference element, Action<TextEditContextUpdate> onTextUpdate, TextEditContextOptions? options = null, Action<bool>? onComposition = null, Action<TextEditFormat[]>? onFormatUpdate = null)ValueTask<string?> GetText()ValueTask<TextEditSelection?> GetSelection()ValueTask UpdateText(int rangeStart, int rangeEnd, string text)ValueTask UpdateSelection(int start, int end)ValueTask UpdateControlBounds(double x, double y, double width, double height)ValueTask UpdateSelectionBounds(double x, double y, double width, double height)ValueTask DisposeAsync()ValueTask DisposeAsync()