From ec130f539d55d466589a7c4c37f3c242f5e27a3e Mon Sep 17 00:00:00 2001 From: bombizombi Date: Fri, 28 Nov 2025 04:28:40 +0100 Subject: [PATCH] Fix virtual keyboard on browser/mobile Android (#11665) --- .../Avalonia.Browser/BrowserInputHandler.cs | 13 ++++- .../BrowserTextInputMethod.cs | 40 ++++++++++++++- .../Avalonia.Browser/Interop/InputHelper.cs | 12 ++--- .../webapp/modules/avalonia/dom.ts | 1 + .../webapp/modules/avalonia/input.ts | 49 +++++++++++++++---- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/Browser/Avalonia.Browser/BrowserInputHandler.cs b/src/Browser/Avalonia.Browser/BrowserInputHandler.cs index 40e7a3da57..89743007b5 100644 --- a/src/Browser/Avalonia.Browser/BrowserInputHandler.cs +++ b/src/Browser/Avalonia.Browser/BrowserInputHandler.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Runtime.InteropServices.JavaScript; using Avalonia.Browser.Interop; using Avalonia.Collections.Pooled; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Raw; @@ -40,7 +41,7 @@ internal class BrowserInputHandler TextInputMethod = new BrowserTextInputMethod(this, container, inputElement); InputPane = new BrowserInputPane(); - InputHelper.SubscribeInputEvents(container, topLevelId); + InputHelper.SubscribeInputEvents(container, inputElement, topLevelId); } public BrowserTextInputMethod TextInputMethod { get; } @@ -229,6 +230,14 @@ internal class BrowserInputHandler public bool OnKeyDown(string code, string key, int modifier) { + //If we are processing beforeInput we must not process Backspace + //but onBeforeInput itself calls OnKeyBackspace, so filtering must be done at the same level. + //onBeforeInput will call RawKeyboardEvent. + if (key == "Backspace") + { + //return true; //why? + } + var handled = RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier); if (!handled && key.Length == 1) @@ -303,7 +312,7 @@ internal class BrowserInputHandler return false; } - private bool RawKeyboardEvent(RawKeyEventType type, string domCode, string domKey, RawInputModifiers modifiers) + internal bool RawKeyboardEvent(RawKeyEventType type, string domCode, string domKey, RawInputModifiers modifiers) { if (_inputRoot is null) return false; diff --git a/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs index 11722851b7..9fa0145850 100644 --- a/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs +++ b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs @@ -1,6 +1,8 @@ using System; using System.Runtime.InteropServices.JavaScript; using Avalonia.Browser.Interop; +using Avalonia.Input; +using Avalonia.Input.Raw; using Avalonia.Input.TextInput; namespace Avalonia.Browser; @@ -29,12 +31,14 @@ internal class BrowserTextInputMethod( if (_client != null) { _client.SurroundingTextChanged -= SurroundingTextChanged; + _client.SelectionChanged -= SelectionCHanged; _client.InputPaneActivationRequested -= InputPaneActivationRequested; } if (client != null) { client.SurroundingTextChanged += SurroundingTextChanged; + client.SelectionChanged += SelectionCHanged; client.InputPaneActivationRequested += InputPaneActivationRequested; } @@ -81,6 +85,17 @@ internal class BrowserTextInputMethod( InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); } } + private void SelectionCHanged(object? sender, EventArgs e) + { + if (_client != null) + { + var surroundingText = _client.SurroundingText ?? ""; + var selection = _client.Selection; + + InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); + } + } + public void SetCursorRect(Rect rect) { @@ -100,8 +115,11 @@ internal class BrowserTextInputMethod( InputHelper.SetSurroundingText(_inputElement, "", 0, 0); } - public void OnBeforeInput(string inputType, int start, int end) + public void OnBeforeInput(string inputType, int start, int end, string data) { + bool handled; + + /* if (inputType != "deleteByComposition") { if (inputType == "deleteContentBackward") @@ -115,11 +133,29 @@ internal class BrowserTextInputMethod( end = -1; } } + */ if (start != -1 && end != -1 && _client != null) { _client.Selection = new TextSelection(start, end); } + + if ((inputType == "insertText") && (data.Length > 0)) + { + handled = _inputHandler.RawTextEvent(data); + } + + if (inputType == "deleteContentBackward") + { + handled = _inputHandler.RawKeyboardEvent(RawKeyEventType.KeyDown, "Backspace", "Backspace", (RawInputModifiers)0); + } + + if ((inputType == "insertCompositionText") && (data.Length > 0)) + { + handled = _inputHandler.RawTextEvent(data); + } + + } public void OnCompositionStart() @@ -150,7 +186,7 @@ internal class BrowserTextInputMethod( if (data != null) { - _inputHandler.RawTextEvent(data); + //_inputHandler.RawTextEvent(data); //handled on beforeinput event } } } diff --git a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs index bb2884b064..b3ef380db3 100644 --- a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs @@ -12,7 +12,7 @@ internal static partial class InputHelper return Task.CompletedTask; } - public static Task RedirectInputRetunAsync(int topLevelId, Func handler, T @default) + public static Task RedirectInputReturnAsync(int topLevelId, Func handler, T @default) { if (BrowserTopLevelImpl.TryGetTopLevel(topLevelId) is { } topLevelImpl) return Task.FromResult(handler(topLevelImpl)); @@ -20,19 +20,19 @@ internal static partial class InputHelper } [JSImport("InputHelper.subscribeInputEvents", AvaloniaModule.MainModuleName)] - public static partial void SubscribeInputEvents(JSObject htmlElement, int topLevelId); + public static partial void SubscribeInputEvents(JSObject htmlElement, JSObject inputElement, int topLevelId); [JSExport] public static Task OnKeyDown(int topLevelId, string code, string key, int modifier) => - RedirectInputRetunAsync(topLevelId, t => t.InputHandler.OnKeyDown(code, key, modifier), false); + RedirectInputReturnAsync(topLevelId, t => t.InputHandler.OnKeyDown(code, key, modifier), false); [JSExport] public static Task OnKeyUp(int topLevelId, string code, string key, int modifier) => - RedirectInputRetunAsync(topLevelId, t => t.InputHandler.OnKeyUp(code, key, modifier), false); + RedirectInputReturnAsync(topLevelId, t => t.InputHandler.OnKeyUp(code, key, modifier), false); [JSExport] - public static Task OnBeforeInput(int topLevelId, string inputType, int start, int end) => - RedirectInputAsync(topLevelId, t => t.InputHandler.TextInputMethod.OnBeforeInput(inputType, start, end)); + public static Task OnBeforeInput(int topLevelId, string inputType, int start, int end, string data) => + RedirectInputAsync(topLevelId, t => t.InputHandler.TextInputMethod.OnBeforeInput(inputType, start, end, data)); [JSExport] public static Task OnCompositionStart(int topLevelId) => diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts index 175e51c0da..7aa6cebf3a 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts @@ -72,6 +72,7 @@ export class AvaloniaDOM { // IME const inputElement = document.createElement("input"); + inputElement.contentEditable = "true"; inputElement.id = `inputElement${containerId}`; inputElement.classList.add("avalonia-input-element"); inputElement.autocapitalize = "none"; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts index e70c43ebed..4edbd19a57 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts @@ -73,6 +73,7 @@ export class InputHelper { static clipboardState: ClipboardState = ClipboardState.None; static resolveClipboard?: (value: readonly ReadableDataItem[]) => void; static rejectClipboard?: (reason?: any) => void; + static enableIME: boolean = false; // hack, once turned on, stays on public static initializeBackgroundHandlers() { if (this.clipboardState !== ClipboardState.None) { @@ -302,10 +303,10 @@ export class InputHelper { : new Uint8Array(await blob.arrayBuffer()); } - public static subscribeInputEvents(element: HTMLInputElement, topLevelId: number) { - const keySub = this.subscribeKeyEvents(element, topLevelId); + public static subscribeInputEvents(element: HTMLInputElement, inputElement: HTMLInputElement, topLevelId: number) { + const keySub = this.subscribeKeyEvents(element, inputElement, topLevelId); const pointerSub = this.subscribePointerEvents(element, topLevelId); - const textSub = this.subscribeTextEvents(element, topLevelId); + const textSub = this.subscribeTextEvents(element, inputElement, topLevelId); const dndSub = this.subscribeDropEvents(element, topLevelId); const paneSub = this.subscribeKeyboardGeometryChange(element, topLevelId); @@ -318,11 +319,22 @@ export class InputHelper { }; } - public static subscribeKeyEvents(element: HTMLInputElement, topLevelId: number) { + public static subscribeKeyEvents(element: HTMLInputElement, inputElement: HTMLInputElement, topLevelId: number) { const keyDownHandler = (args: KeyboardEvent) => { + if (args.keyCode === 229) { + InputHelper.enableIME = true; + } + + if (InputHelper.enableIME) { + if (args.key === "Backspace") { + return; + } + } + JsExports.InputHelper.OnKeyDown(topLevelId, args.code, args.key, this.getModifiers(args)) .then((handled: boolean) => { - if (!handled || this.clipboardState !== ClipboardState.Pending) { + // if (!handled || this.clipboardState !== ClipboardState.Pending) { + if (!handled) { args.preventDefault(); } }); @@ -352,6 +364,7 @@ export class InputHelper { public static subscribeTextEvents( element: HTMLInputElement, + inputElement: HTMLInputElement, topLevelId: number) { const compositionStartHandler = (args: CompositionEvent) => { JsExports.InputHelper.OnCompositionStart(topLevelId); @@ -359,6 +372,12 @@ export class InputHelper { element.addEventListener("compositionstart", compositionStartHandler); const beforeInputHandler = (args: InputEvent) => { + if (!InputHelper.enableIME) { + args.preventDefault(); // we modified the _input, so do not modify it twice + return; // do not process beforeInput + } + + // this has no chance of working, since we have input type=text const ranges = args.getTargetRanges(); let start = -1; let end = -1; @@ -367,12 +386,21 @@ export class InputHelper { end = ranges[0].endOffset; } - if (args.inputType === "insertCompositionText") { - start = 2; - end = start + 2; - } + // wtf + // if (args.inputType === "insertCompositionText") { + // start = 2; + // end = start + 2; + // } - JsExports.InputHelper.OnBeforeInput(topLevelId, args.inputType, start, end); + // start and end here are uninitialized, { element.removeEventListener("compositionstart", compositionStartHandler); + element.removeEventListener("beforeinput", beforeInputHandler); element.removeEventListener("compositionupdate", compositionUpdateHandler); element.removeEventListener("compositionend", compositionEndHandler); };