diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index d41a397c12..c17d32ec17 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Input; using Avalonia.Input.TextInput; using Avalonia.Media; +using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -57,7 +58,7 @@ namespace Avalonia.Controls public TextInputMethodSurroundingText SurroundingText => new() { Text = _presenter?.Text ?? "", - CursorOffset = _presenter?.SelectionEnd ?? 0, + CursorOffset = _presenter?.CaretIndex ?? 0, AnchorOffset = _presenter?.SelectionStart ?? 0 }; @@ -73,7 +74,8 @@ namespace Avalonia.Controls _parent.SelectionEnd = end; } - private void OnCaretBoundsChanged(object? sender, EventArgs e) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty); + private void OnCaretBoundsChanged(object? sender, EventArgs e) => + Dispatcher.UIThread.Post(() => CursorRectangleChanged?.Invoke(this, EventArgs.Empty), DispatcherPriority.Input); private void OnTextBoxPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor index 293f55be01..180c090c03 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor @@ -48,13 +48,13 @@ } #inputElement { - opacity: 0.2; + opacity: 0.5; position: absolute; - width: 400px; - height: 75px; + height: 20px; top: 0px; left: 0px; z-index: 1000; + overflow: hidden; } diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs index 7a386d5683..edd9c3e6e3 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs @@ -349,12 +349,12 @@ namespace Avalonia.Web.Blazor IsComposing = true; break; case WebCompositionEventArgs.WebCompositionEventType.Update: - _client.SetPreeditText(e.Data); + _client?.SetPreeditText(e.Data); break; case WebCompositionEventArgs.WebCompositionEventType.End: IsComposing = false; - _client.SetPreeditText(null); - _topLevelImpl.RawTextEvent(e.Data); + _client?.SetPreeditText(null); + _topLevelImpl.RawTextEvent(e.Data); break; } } @@ -452,11 +452,15 @@ namespace Avalonia.Web.Blazor _client = client; - if (IsActive) + if (IsActive && _client != null) { _inputHelper.Show(); _inputElementFocused = true; _inputHelper.Focus(); + + var surroundingText = _client.SurroundingText; + + _inputHelper?.SetSurroundingText(surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); } else { @@ -467,28 +471,28 @@ namespace Avalonia.Web.Blazor private void SurroundingTextChanged(object? sender, EventArgs e) { - if(_client != null) + if(_client != null && IsComposing) { var surroundingText = _client.SurroundingText; - + _inputHelper?.SetSurroundingText(surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); } } public void SetCursorRect(Rect rect) { + _inputHelper?.Focus(); var bounds = new PixelRect((int)rect.X, (int) rect.Y, (int) rect.Width, (int) rect.Height); - _inputHelper?.SetBounds(bounds); - - if (_client != null && _client.SupportsSurroundingText && !IsComposing) + if (_client != null) { var surroundingText = _client.SurroundingText; - _inputHelper?.SetSurroundingText(surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); + _inputHelper?.SetSurroundingText(surroundingText.Text, surroundingText.AnchorOffset, + surroundingText.CursorOffset); + + _inputHelper?.SetBounds(bounds, surroundingText.CursorOffset); } - - _inputHelper?.Focus(); } public void SetOptions(TextInputOptions options) diff --git a/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs index debe23e302..8872339f91 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/InputHelperInterop.cs @@ -122,9 +122,9 @@ namespace Avalonia.Web.Blazor.Interop _module.Invoke(SetSurroundingTextSymbol, _inputElement, text, start, end); } - public void SetBounds(PixelRect bounds) + public void SetBounds(PixelRect bounds, int caret) { - _module.Invoke(SetBoundsSymbol, _inputElement, bounds.X, bounds.Y, bounds.Width, bounds.Height); + _module.Invoke(SetBoundsSymbol, _inputElement, bounds.X, bounds.Y, bounds.Width, bounds.Height, caret); } } } diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia.ts index 396126a1a6..369f628a44 100644 --- a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia.ts +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia.ts @@ -4,3 +4,4 @@ export { FocusHelper } from "./Avalonia/FocusHelper" export { NativeControlHost } from "./Avalonia/NativeControlHost" export { SizeWatcher } from "./Avalonia/SizeWatcher" export { SKHtmlCanvas } from "./Avalonia/SKHtmlCanvas" +export { CaretHelper } from "./Avalonia/CaretHelper" diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.js b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.js deleted file mode 100644 index ef20585657..0000000000 --- a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.js +++ /dev/null @@ -1,146 +0,0 @@ -(function () { - -// We'll copy the properties below into the mirror div. -// Note that some browsers, such as Firefox, do not concatenate properties -// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding), -// so we have to list every single property explicitly. - var properties = [ - 'direction', // RTL support - 'boxSizing', - 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does - 'height', - 'overflowX', - 'overflowY', // copy the scrollbar for IE - - 'borderTopWidth', - 'borderRightWidth', - 'borderBottomWidth', - 'borderLeftWidth', - 'borderStyle', - - 'paddingTop', - 'paddingRight', - 'paddingBottom', - 'paddingLeft', - - // https://developer.mozilla.org/en-US/docs/Web/CSS/font - 'fontStyle', - 'fontVariant', - 'fontWeight', - 'fontStretch', - 'fontSize', - 'fontSizeAdjust', - 'lineHeight', - 'fontFamily', - - 'textAlign', - 'textTransform', - 'textIndent', - 'textDecoration', // might not make a difference, but better be safe - - 'letterSpacing', - 'wordSpacing', - - 'tabSize', - 'MozTabSize' - - ]; - - var isBrowser = (typeof window !== 'undefined'); - var isFirefox = (isBrowser && window.mozInnerScreenX != null); - - -function getCaretCoordinates(element, position, options) { - if (!isBrowser) { - throw new Error('textarea-caret-position#getCaretCoordinates should only be called in a browser'); - } - - var debug = options && options.debug || false; - if (debug) { - var el = document.querySelector('#input-textarea-caret-position-mirror-div'); - if (el) el.parentNode.removeChild(el); - } - - // The mirror div will replicate the textarea's style - var div = document.createElement('div'); - div.id = 'input-textarea-caret-position-mirror-div'; - document.body.appendChild(div); - - var style = div.style; - var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 - var isInput = element.nodeName === 'INPUT'; - - // Default textarea styles - style.whiteSpace = 'pre-wrap'; - if (!isInput) - style.wordWrap = 'break-word'; // only for textarea-s - - // Position off-screen - style.position = 'absolute'; // required to return coordinates properly - if (!debug) - style.visibility = 'hidden'; // not 'display: none' because we want rendering - - // Transfer the element's properties to the div - properties.forEach(function (prop) { - if (isInput && prop === 'lineHeight') { - // Special case for s because text is rendered centered and line height may be != height - if (computed.boxSizing === "border-box") { - var height = parseInt(computed.height); - var outerHeight = - parseInt(computed.paddingTop) + - parseInt(computed.paddingBottom) + - parseInt(computed.borderTopWidth) + - parseInt(computed.borderBottomWidth); - var targetHeight = outerHeight + parseInt(computed.lineHeight); - if (height > targetHeight) { - style.lineHeight = height - outerHeight + "px"; - } else if (height === targetHeight) { - style.lineHeight = computed.lineHeight; - } else { - style.lineHeight = 0; - } - } else { - style.lineHeight = computed.height; - } - } else { - style[prop] = computed[prop]; - } - }); - - if (isFirefox) { - // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 - if (element.scrollHeight > parseInt(computed.height)) - style.overflowY = 'scroll'; - } else { - style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' - } - - div.textContent = element.value.substring(0, position); - // The second special handling for input type="text" vs textarea: - // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 - if (isInput) - div.textContent = div.textContent.replace(/\s/g, '\u00a0'); - - var span = document.createElement('span'); - // Wrapping must be replicated *exactly*, including when a long word gets - // onto the next line, with whitespace at the end of the line before (#7). - // The *only* reliable way to do that is to copy the *entire* rest of the - // textarea's content into the created at the caret position. - // For inputs, just '.' would be enough, but no need to bother. - span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all - div.appendChild(span); - - var coordinates = { - top: span.offsetTop + parseInt(computed['borderTopWidth']), - left: span.offsetLeft + parseInt(computed['borderLeftWidth']), - height: parseInt(computed['lineHeight']) - }; - - if (debug) { - span.style.backgroundColor = '#aaa'; - } else { - document.body.removeChild(div); - } - - return coordinates; -} diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.ts new file mode 100644 index 0000000000..5709854087 --- /dev/null +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/CaretHelper.ts @@ -0,0 +1,149 @@ +// Based on https://github.com/component/textarea-caret-position/blob/master/index.js +export class CaretHelper { + public static getCaretCoordinates( + element: HTMLInputElement | HTMLTextAreaElement, + position: number, + options?: { debug: boolean } + ) { + if (!isBrowser) { + throw new Error( + "textarea-caret-position#getCaretCoordinates should only be called in a browser" + ); + } + + const debug = (options && options.debug) || false; + if (debug) { + const el = document.querySelector( + "#input-textarea-caret-position-mirror-div" + ); + if (el) el.parentNode?.removeChild(el); + } + + // The mirror div will replicate the textarea's style + const div = document.createElement("div"); + div.id = "input-textarea-caret-position-mirror-div"; + document.body.appendChild(div); + + const style = div.style; + const computed = window.getComputedStyle + ? window.getComputedStyle(element) + : ((element as any)["currentStyle"] as CSSStyleDeclaration); // currentStyle for IE < 9 + const isInput = element.nodeName === "INPUT"; + + // Default textarea styles + style.whiteSpace = "pre-wrap"; + if (!isInput) style.wordWrap = "break-word"; // only for textarea-s + + // Position off-screen + style.position = "absolute"; // required to return coordinates properly + if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering + + // Transfer the element's properties to the div + properties.forEach((prop: string) => { + if (isInput && prop === "lineHeight") { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === "border-box") { + const height = parseInt(computed.height); + const outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth); + const targetHeight = outerHeight + parseInt(computed.lineHeight); + if (height > targetHeight) { + style.lineHeight = height - outerHeight + "px"; + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight; + } else { + style.lineHeight = "0"; + } + } else { + style.lineHeight = computed.height; + } + } else { + (style as any)[prop] = (computed as any)[prop]; + } + }); + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = "scroll"; + } else { + style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, position); + // The second special handling for input type="text" vs textarea: + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 + if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0"); + + const span = document.createElement("span"); + // Wrapping must be replicated *exactly*, including when a long word gets + // onto the next line, with whitespace at the end of the line before (#7). + // The *only* reliable way to do that is to copy the *entire* rest of the + // textarea's content into the created at the caret position. + // For inputs, just '.' would be enough, but no need to bother. + span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + parseInt(computed["borderTopWidth"]), + left: span.offsetLeft + parseInt(computed["borderLeftWidth"]), + height: parseInt(computed["lineHeight"]), + }; + + if (debug) { + span.style.backgroundColor = "#aaa"; + } else { + document.body.removeChild(div); + } + + return coordinates; + } +} + + +var properties = [ + "direction", // RTL support + "boxSizing", + "width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + "height", + "overflowX", + "overflowY", // copy the scrollbar for IE + + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderStyle", + + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + "fontStyle", + "fontVariant", + "fontWeight", + "fontStretch", + "fontSize", + "fontSizeAdjust", + "lineHeight", + "fontFamily", + + "textAlign", + "textTransform", + "textIndent", + "textDecoration", // might not make a difference, but better be safe + + "letterSpacing", + "wordSpacing", + + "tabSize", + "MozTabSize", +]; + +const isBrowser = typeof window !== "undefined"; +const isFirefox = isBrowser && (window as any).mozInnerScreenX != null; diff --git a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts index 7886825f32..15170c170a 100644 --- a/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts +++ b/src/Web/Avalonia.Web.Blazor/webapp/modules/Avalonia/InputHelper.ts @@ -1,6 +1,8 @@ -export class InputHelper { +import {CaretHelper} from "./CaretHelper"; + +export class InputHelper { static inputCallback?: DotNet.DotNetObject; - static compositionCallback?: DotNet.DotNetObject + static compositionCallback?: DotNet.DotNetObject public static start(inputElement: HTMLInputElement, compositionCallback: DotNet.DotNetObject, inputCallback: DotNet.DotNetObject) { @@ -27,14 +29,17 @@ inputElement.style.cursor = kind; } - public static setBounds(inputElement: HTMLInputElement, x: number, y: number, width: number, height: number) + public static setBounds(inputElement: HTMLInputElement, x: number, y: number, width: number, height: number, caret: number) { - inputElement.style.left = (x - 5).toFixed(0) + "px"; - inputElement.style.top = (y - 5).toFixed(0) + "px"; - inputElement.style.height = "20px"; - inputElement.style.width = "200px"; + if(inputElement.selectionStart) { + inputElement.style.left = (x).toFixed(0) + "px"; + inputElement.style.top = (y).toFixed(0) + "px"; + + let {height, left, top} = CaretHelper.getCaretCoordinates(inputElement, caret); - getCaretCoordinates(inputElement, inputElement.selectionEnd); + inputElement.style.left = (x - left).toFixed(0) + "px"; + inputElement.style.top = (y - top).toFixed(0) + "px"; + } } public static hide(inputElement: HTMLInputElement) {